@@ -11,6 +11,7 @@ import { MappingNotFoundError } from '../utils/errors.js';
1111import { ensureDir } from '../utils/file-utils.js' ;
1212import { logger } from '../utils/logger.js' ;
1313import { getMojmapTinyPath } from '../utils/paths.js' ;
14+ import { getVersionManager } from './version-manager.js' ;
1415
1516/**
1617 * Manages mapping downloads and caching
@@ -19,87 +20,88 @@ export class MappingService {
1920 private mojangDownloader = getMojangDownloader ( ) ;
2021 private fabricMaven = getFabricMaven ( ) ;
2122 private cache = getCacheManager ( ) ;
23+ private versionManager = getVersionManager ( ) ;
2224
2325 // Lock to prevent concurrent downloads of the same mappings
2426 private downloadLocks = new Map < string , Promise < string > > ( ) ;
2527
2628 /**
27- * Get or download mappings for a version
28- * Uses locking to prevent concurrent downloads of the same mapping
29+ * Get or download mappings for a version.
30+ * Flow: cache → dedupe lock → unobfuscated guard → recheck lock → download.
2931 */
3032 async getMappings ( version : string , mappingType : MappingType ) : Promise < string > {
3133 const lockKey = `${ version } -${ mappingType } ` ;
3234
33- // For Mojmap, check for converted Tiny file first (not raw ProGuard)
34- if ( mappingType === 'mojmap' ) {
35- const convertedPath = getMojmapTinyPath ( version ) ;
36- if ( existsSync ( convertedPath ) ) {
37- logger . info ( `Using cached Mojmap (Tiny format) mappings for ${ version } : ${ convertedPath } ` ) ;
38- return convertedPath ;
39- }
40-
41- // Check if download is already in progress
42- const existingDownload = this . downloadLocks . get ( lockKey ) ;
43- if ( existingDownload ) {
44- logger . info ( `Waiting for existing Mojmap download of ${ version } to complete` ) ;
45- return existingDownload ;
46- }
47-
48- // Download and convert Mojmap with lock
49- const downloadPromise = this . downloadAndConvertMojmap ( version ) ;
50- this . downloadLocks . set ( lockKey , downloadPromise ) ;
51- try {
52- return await downloadPromise ;
53- } finally {
54- this . downloadLocks . delete ( lockKey ) ;
55- }
56- }
57-
58- // Check cache first for other mapping types
59- const cachedPath = this . cache . getMappingPath ( version , mappingType ) ;
35+ // 1. Return immediately from cache without any network access
36+ const cachedPath = this . getCachedMapping ( version , mappingType ) ;
6037 if ( cachedPath ) {
6138 logger . info ( `Using cached ${ mappingType } mappings for ${ version } : ${ cachedPath } ` ) ;
6239 return cachedPath ;
6340 }
6441
65- // Check if download is already in progress
42+ // 2. Deduplicate concurrent downloads for the same version+type
6643 const existingDownload = this . downloadLocks . get ( lockKey ) ;
6744 if ( existingDownload ) {
6845 logger . info ( `Waiting for existing ${ mappingType } download of ${ version } to complete` ) ;
6946 return existingDownload ;
7047 }
7148
72- // Download based on type with lock
73- logger . info ( `Downloading ${ mappingType } mappings for ${ version } ` ) ;
74- let downloadPromise : Promise < string > ;
49+ // 3. Unobfuscated versions (26.1+) have no mapping files — check before attempting download
50+ await this . throwIfUnobfuscated ( version , mappingType ) ;
7551
76- switch ( mappingType ) {
77- case 'yarn' :
78- downloadPromise = this . downloadAndExtractYarn ( version ) ;
79- break ;
80- case 'intermediary' :
81- downloadPromise = this . downloadAndExtractIntermediary ( version ) ;
82- break ;
83- default :
84- throw new MappingNotFoundError (
85- version ,
86- mappingType ,
87- `Unsupported mapping type: ${ mappingType } ` ,
88- ) ;
52+ // 4. Recheck lock — another caller may have started a download during the async check above
53+ const postCheckDownload = this . downloadLocks . get ( lockKey ) ;
54+ if ( postCheckDownload ) {
55+ return postCheckDownload ;
8956 }
9057
58+ // 5. Download with lock
59+ logger . info ( `Downloading ${ mappingType } mappings for ${ version } ` ) ;
60+ const downloadPromise = this . startDownload ( version , mappingType ) ;
9161 this . downloadLocks . set ( lockKey , downloadPromise ) ;
92- let mappingPath : string ;
9362 try {
94- mappingPath = await downloadPromise ;
63+ return await downloadPromise ;
9564 } finally {
9665 this . downloadLocks . delete ( lockKey ) ;
9766 }
67+ }
9868
99- // Cache the mapping
100- this . cache . cacheMapping ( version , mappingType , mappingPath ) ;
69+ /**
70+ * Check for a locally cached mapping file without hitting the network.
71+ */
72+ private getCachedMapping ( version : string , mappingType : MappingType ) : string | null {
73+ if ( mappingType === 'mojmap' ) {
74+ const convertedPath = getMojmapTinyPath ( version ) ;
75+ return existsSync ( convertedPath ) ? convertedPath : null ;
76+ }
77+ return this . cache . getMappingPath ( version , mappingType ) ?? null ;
78+ }
10179
102- return mappingPath ;
80+ /**
81+ * Start the actual download for a mapping type.
82+ * Mojmap handles its own caching internally; yarn/intermediary are cached here.
83+ */
84+ private async startDownload ( version : string , mappingType : MappingType ) : Promise < string > {
85+ switch ( mappingType ) {
86+ case 'mojmap' :
87+ return this . downloadAndConvertMojmap ( version ) ;
88+ case 'yarn' : {
89+ const path = await this . downloadAndExtractYarn ( version ) ;
90+ this . cache . cacheMapping ( version , mappingType , path ) ;
91+ return path ;
92+ }
93+ case 'intermediary' : {
94+ const path = await this . downloadAndExtractIntermediary ( version ) ;
95+ this . cache . cacheMapping ( version , mappingType , path ) ;
96+ return path ;
97+ }
98+ default :
99+ throw new MappingNotFoundError (
100+ version ,
101+ mappingType ,
102+ `Unsupported mapping type: ${ mappingType } ` ,
103+ ) ;
104+ }
103105 }
104106
105107 /**
@@ -192,8 +194,7 @@ export class MappingService {
192194 ! parsed . header . namespaces . includes ( 'named' )
193195 ) {
194196 throw new Error (
195- `Invalid mapping-io output: expected namespaces 'intermediary' and 'named', ` +
196- `got ${ parsed . header . namespaces . join ( ', ' ) } `
197+ `Invalid mapping-io output: expected namespaces 'intermediary' and 'named', got ${ parsed . header . namespaces . join ( ', ' ) } ` ,
197198 ) ;
198199 }
199200
@@ -227,6 +228,29 @@ export class MappingService {
227228 // Intermediary should exist for all Fabric-supported versions
228229 }
229230
231+ /**
232+ * Throw a clear error if the version is unobfuscated and no mapping files exist.
233+ * Called just before attempting a download, AFTER cache checks, so that cached
234+ * mappings still work without hitting the network.
235+ */
236+ private async throwIfUnobfuscated ( version : string , mappingType : MappingType ) : Promise < void > {
237+ const isUnobfuscated = await this . versionManager . isVersionUnobfuscated ( version ) ;
238+ if ( ! isUnobfuscated ) return ;
239+
240+ if ( mappingType === 'mojmap' ) {
241+ throw new MappingNotFoundError (
242+ version ,
243+ mappingType ,
244+ `Mojmap mapping files are not available for unobfuscated version ${ version } . The JAR is already in Mojang's human-readable names — decompile it directly with mapping 'mojmap'.` ,
245+ ) ;
246+ }
247+ throw new MappingNotFoundError (
248+ version ,
249+ mappingType ,
250+ `${ mappingType } mappings are not available for unobfuscated version ${ version } . Use 'mojmap' mapping instead — the JAR ships without obfuscation.` ,
251+ ) ;
252+ }
253+
230254 /**
231255 * Lookup result type
232256 */
0 commit comments