3535// game server's workshop downloads: --workshop <dir>, by default
3636// <install>/game/bin/linuxsteamrt64/steamapps/workshop/content/730, holding
3737// one <id>/ folder per item. A map is published under its maps-table name and
38- // read from its VPK under the name inside it. A copy in the install wins over
38+ // read under the name inside the item, from the map VPK the item's addon VPK
39+ // carries (unpacked to TMPDIR for the build). A copy in the install wins over
3940// a workshop one; `_night` variants are skipped (consumers fold them onto the
4041// base map); an item this node never downloaded keeps whatever entry the base
4142// manifest has.
@@ -92,12 +93,14 @@ import { createHash } from "node:crypto";
9293import {
9394 existsSync ,
9495 mkdirSync ,
96+ mkdtempSync ,
9597 readdirSync ,
9698 readFileSync ,
9799 realpathSync ,
98100 rmSync ,
99101 writeFileSync ,
100102} from "node:fs" ;
103+ import { tmpdir } from "node:os" ;
101104import { basename , dirname , join } from "node:path" ;
102105import { fileURLToPath } from "node:url" ;
103106import { gzipSync } from "node:zlib" ;
@@ -127,7 +130,7 @@ import {
127130 retryMaps ,
128131 sameContent ,
129132} from "./lib-manifest.mjs" ;
130- import { resolveCli } from "./lib-s2v.mjs" ;
133+ import { decompile , listVpk , missingOutput , resolveCli } from "./lib-s2v.mjs" ;
131134import { loadPrep , makePrep , prepFileName } from "./replay-stage/build-prep.mjs" ;
132135
133136const root = join ( dirname ( fileURLToPath ( import . meta. url ) ) , ".." ) ;
@@ -238,9 +241,10 @@ function workshopList(text) {
238241}
239242
240243/**
241- * The VPK a workshop item was downloaded as, and the map's name inside it:
242- * `<root>/<id>/<name>.vpk` when there is one, else the item's only map VPK.
243- * null when the item is not there, or holds several VPKs none named `name`.
244+ * The VPK a workshop item was downloaded as: `<root>/<id>/<name>.vpk` when
245+ * there is one, else the item's only VPK (`<id>.vpk`, or `<id>_dir.vpk` with
246+ * its chunks beside it). null when the item is not there, or holds several
247+ * VPKs none named `name`.
244248 */
245249export function workshopVpk ( root , id , name ) {
246250 const dir = join ( root , id ) ;
@@ -251,7 +255,45 @@ export function workshopVpk(root, id, name) {
251255 const pick =
252256 vpks . find ( ( f ) => f === `${ name } .vpk` || f === `${ name } _dir.vpk` ) ??
253257 ( vpks . length === 1 ? vpks [ 0 ] : null ) ;
254- return pick ? { vpk : join ( dir , pick ) , inner : pick . replace ( / ( _ d i r ) ? \. v p k $ / , "" ) } : null ;
258+ return pick ? { vpk : join ( dir , pick ) } : null ;
259+ }
260+
261+ /**
262+ * The map VPK of a workshop item and the map's name inside it. Steam
263+ * downloads an item as an addon VPK that carries the map's own VPK as
264+ * maps/<map>.vpk, beside its skybox's, so that one is unpacked into `dir`;
265+ * an item that is itself a map VPK is read where it is. The map is the one
266+ * named `name`, else the only one that is not a skybox.
267+ */
268+ export function workshopMap ( cli , vpk , name , dir ) {
269+ const maps = [ ] ;
270+ for ( const path of listVpk ( cli , vpk , "maps/" ) ) {
271+ const nested = / ^ m a p s \/ ( [ ^ / ] + ) \. v p k $ / . exec ( path ) ?. [ 1 ] ;
272+ const own = / ^ m a p s \/ ( [ ^ / ] + ) \/ w o r l d _ p h y s i c s \. v m d l _ c $ / . exec ( path ) ?. [ 1 ] ;
273+ if ( nested || own ) {
274+ maps . push ( { inner : nested ?? own , nested : Boolean ( nested ) } ) ;
275+ }
276+ }
277+ const playable = maps . filter ( ( m ) => ! m . inner . includes ( "skybox" ) ) ;
278+ const pick =
279+ maps . find ( ( m ) => m . inner === name ) ?? ( playable . length === 1 ? playable [ 0 ] : null ) ;
280+ if ( ! pick ) {
281+ throw new Error (
282+ playable . length
283+ ? `${ basename ( vpk ) } holds ${ playable . map ( ( m ) => m . inner ) . join ( ", " ) } , none of them ${ name } `
284+ : `${ basename ( vpk ) } holds no map` ,
285+ ) ;
286+ }
287+ if ( ! pick . nested ) {
288+ return { vpk, inner : pick . inner } ;
289+ }
290+ const file = `maps/${ pick . inner } .vpk` ;
291+ const run = decompile ( cli , vpk , file , dir ) ;
292+ const unpacked = join ( dir , file ) ;
293+ if ( ! existsSync ( unpacked ) ) {
294+ throw missingOutput ( file , run ) ;
295+ }
296+ return { vpk : unpacked , inner : pick . inner } ;
255297}
256298
257299/** Writes `body` raw and gzipped; returns the manifest's `{ file, sha256 }`. */
@@ -376,7 +418,7 @@ async function prepPatch(map, entry, { store, prep, assets, rawDir, filesDir })
376418 } ;
377419}
378420
379- async function buildMap ( map , { from, rawDir, filesDir, cli, pak, prep, assets } ) {
421+ async function buildMap ( map , { from, scratch , rawDir, filesDir, cli, pak, prep, assets } ) {
380422 const start = performance . now ( ) ;
381423 for ( const asset of assets ) {
382424 rmSync ( join ( filesDir , asset . file ( map ) ) , { force : true } ) ;
@@ -389,9 +431,13 @@ async function buildMap(map, { from, rawDir, filesDir, cli, pak, prep, assets })
389431 viewFailed : false ,
390432 } ;
391433
434+ let input = from ;
392435 let meshes ;
393436 try {
394- meshes = await extractMap ( map , { vpk : from . vpk , inner : from . inner , outDir : rawDir , cli } ) ;
437+ if ( from . workshop ) {
438+ input = workshopMap ( cli , from . vpk , map , scratch ) ;
439+ }
440+ meshes = await extractMap ( map , { vpk : input . vpk , inner : input . inner , outDir : rawDir , cli } ) ;
395441 if ( ! meshes . tri ) {
396442 throw new Error ( `collision mesh over the cap (${ meshes . note } )` ) ;
397443 }
@@ -404,7 +450,7 @@ async function buildMap(map, { from, rawDir, filesDir, cli, pak, prep, assets })
404450
405451 let callouts ;
406452 try {
407- callouts = extractCallouts ( map , { vpk : from . vpk , inner : from . inner , pak, cli } ) ;
453+ callouts = extractCallouts ( map , { vpk : input . vpk , inner : input . inner , pak, cli } ) ;
408454 } catch ( error ) {
409455 callouts = { failed : error . message } ;
410456 result . calloutsFailed = true ;
@@ -441,8 +487,8 @@ async function buildMap(map, { from, rawDir, filesDir, cli, pak, prep, assets })
441487 let blasted = false ;
442488 try {
443489 const blast = extractBlast ( map , {
444- vpk : from . vpk ,
445- inner : from . inner ,
490+ vpk : input . vpk ,
491+ inner : input . inner ,
446492 cli,
447493 targets : callouts . bombTargets ?? [ ] ,
448494 callouts : callouts . callouts ?? [ ] ,
@@ -491,7 +537,7 @@ async function buildMap(map, { from, rawDir, filesDir, cli, pak, prep, assets })
491537 console . log (
492538 `${ map . padEnd ( 18 ) } ${ describe ( meshes ) } ; callouts ${ calloutNote } ; ` +
493539 `prep ${ prepNote } ; blast ${ blastNote } ` +
494- `${ from . workshop ? `; workshop ${ from . workshop } (${ basename ( from . vpk ) } )` : "" } \n` +
540+ `${ from . workshop ? `; workshop ${ from . workshop } (${ basename ( input . vpk ) } )` : "" } \n` +
495541 `${ "" . padEnd ( 18 ) } ${ sizes . join ( ", " ) } ` +
496542 `[${ seconds ( performance . now ( ) - start ) } : ${ stages } ]` ,
497543 ) ;
@@ -828,11 +874,25 @@ async function build(args) {
828874 results . push ( { map, unchanged : true } ) ;
829875 continue ;
830876 }
831- const result = await buildMap ( map , { from, rawDir, filesDir, cli, pak, prep, assets } ) ;
832- results . push ( {
833- ...result ,
834- source : { ...source , ...( result . files . prep ? { prep : prep . PREP_VERSION } : { } ) } ,
835- } ) ;
877+ const scratch = mkdtempSync ( join ( tmpdir ( ) , `map-${ map } -` ) ) ;
878+ try {
879+ const result = await buildMap ( map , {
880+ from,
881+ scratch,
882+ rawDir,
883+ filesDir,
884+ cli,
885+ pak,
886+ prep,
887+ assets,
888+ } ) ;
889+ results . push ( {
890+ ...result ,
891+ source : { ...source , ...( result . files . prep ? { prep : prep . PREP_VERSION } : { } ) } ,
892+ } ) ;
893+ } finally {
894+ rmSync ( scratch , { recursive : true , force : true } ) ;
895+ }
836896 }
837897 const attempted = results . filter ( ( r ) => ! r . unchanged && ! r . patch ) ;
838898 const fresh = attempted . filter ( ( r ) => ! r . triFailed ) . length ;
0 commit comments