Skip to content

Commit ef6dd22

Browse files
authored
Merge pull request #7 from MCDxAI/fix/unobfuscated-mapping-service
fix: add unobfuscated version awareness to MappingService
2 parents 8735d86 + 59d4cec commit ef6dd22

6 files changed

Lines changed: 166 additions & 57 deletions

File tree

__tests__/core/mapping-service.test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { existsSync, readFileSync } from 'node:fs';
22
import { describe, expect, it } from 'vitest';
33
import { getMappingService } from '../../src/services/mapping-service.js';
4-
import { TEST_MAPPING, TEST_VERSION } from '../test-constants.js';
4+
import { TEST_MAPPING, TEST_VERSION, UNOBFUSCATED_TEST_VERSION } from '../test-constants.js';
55

66
/**
77
* Mapping Service Tests
@@ -434,3 +434,63 @@ describe('Mojmap Tiny v2 Structure Verification', () => {
434434
expect(firstLine).toContain('named');
435435
}, 180000);
436436
});
437+
438+
/**
439+
* Unobfuscated version handling (26.1+)
440+
*
441+
* Unobfuscated Minecraft versions ship JARs without obfuscation.
442+
* No intermediary, yarn, or mojmap mapping files exist for these versions.
443+
* MappingService.getMappings() and lookupMapping() must fail with clear,
444+
* actionable error messages instead of cryptic download failures.
445+
*
446+
* Reproduces: https://github.com/MCDxAI/minecraft-dev-mcp/issues/5
447+
*/
448+
describe('Unobfuscated version handling', () => {
449+
it('should throw actionable error for getMappings(intermediary) on unobfuscated version', async () => {
450+
const mappingService = getMappingService();
451+
await expect(
452+
mappingService.getMappings(UNOBFUSCATED_TEST_VERSION, 'intermediary'),
453+
).rejects.toThrow(/unobfuscated.*mojmap/is);
454+
}, 30000);
455+
456+
it('should throw actionable error for getMappings(yarn) on unobfuscated version', async () => {
457+
const mappingService = getMappingService();
458+
await expect(
459+
mappingService.getMappings(UNOBFUSCATED_TEST_VERSION, 'yarn'),
460+
).rejects.toThrow(/unobfuscated.*mojmap/is);
461+
}, 30000);
462+
463+
it('should throw actionable error for getMappings(mojmap) on unobfuscated version', async () => {
464+
const mappingService = getMappingService();
465+
await expect(
466+
mappingService.getMappings(UNOBFUSCATED_TEST_VERSION, 'mojmap'),
467+
).rejects.toThrow(/unobfuscated.*already in Mojang/is);
468+
}, 30000);
469+
470+
it('should throw actionable error for lookupMapping on unobfuscated version', async () => {
471+
const mappingService = getMappingService();
472+
// lookupMapping calls getMappings internally, which throws for unobfuscated versions
473+
await expect(
474+
mappingService.lookupMapping(
475+
UNOBFUSCATED_TEST_VERSION,
476+
'Entity',
477+
'mojmap',
478+
'yarn',
479+
),
480+
).rejects.toThrow(/unobfuscated/i);
481+
}, 30000);
482+
483+
it('should allow same-type lookupMapping on unobfuscated version (identity)', async () => {
484+
const mappingService = getMappingService();
485+
// Same source and target mapping should still return identity (no mapping file needed)
486+
const result = await mappingService.lookupMapping(
487+
UNOBFUSCATED_TEST_VERSION,
488+
'net/minecraft/world/entity/Entity',
489+
'mojmap',
490+
'mojmap',
491+
);
492+
expect(result.found).toBe(true);
493+
expect(result.source).toBe('net/minecraft/world/entity/Entity');
494+
expect(result.target).toBe('net/minecraft/world/entity/Entity');
495+
}, 10000);
496+
});

__tests__/core/version-manager.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,5 +70,16 @@ describe('Version Management', () => {
7070
const result = await versionManager.isVersionUnobfuscated('26.1-snapshot-9');
7171
expect(result).toBe(true);
7272
}, 30000);
73+
74+
// Regression tests for https://github.com/MCDxAI/minecraft-dev-mcp/issues/5
75+
it.each([
76+
'26.1-snapshot-10',
77+
'26.1-snapshot-11',
78+
'26.1-rc-3',
79+
])('should return true for %s (issue #5)', async (version) => {
80+
const versionManager = getVersionManager();
81+
const result = await versionManager.isVersionUnobfuscated(version);
82+
expect(result).toBe(true);
83+
}, 30000);
7384
});
7485
});

__tests__/tools/core-tools.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,20 @@ describe('Decompile and Remap Tools', () => {
560560
expect(text).toMatch(/use ['"]mojmap['"] mapping/i);
561561
}, 120000);
562562

563+
it('should return actionable error for find_mapping on unobfuscated version', async () => {
564+
const result = await handleFindMapping({
565+
symbol: 'Entity',
566+
version: UNOBFUSCATED_TEST_VERSION,
567+
sourceMapping: 'mojmap',
568+
targetMapping: 'yarn',
569+
});
570+
571+
expect(result).toBeDefined();
572+
expect(result.isError).toBe(true);
573+
const text = result.content[0].text;
574+
expect(text).toMatch(/unobfuscated/i);
575+
}, 60000);
576+
563577
it('should handle remap_mod_jar with Fabric mod', async () => {
564578
// Skip if fixture doesn't exist
565579
if (!existsSync(METEOR_JAR_PATH)) {

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mcdxai/minecraft-dev-mcp",
3-
"version": "1.0.0",
3+
"version": "1.1.0",
44
"description": "MCP server for Minecraft mod development - decompile, remap, and explore Minecraft source code",
55
"type": "module",
66
"main": "./dist/index.js",

src/services/mapping-service.ts

Lines changed: 77 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { MappingNotFoundError } from '../utils/errors.js';
1111
import { ensureDir } from '../utils/file-utils.js';
1212
import { logger } from '../utils/logger.js';
1313
import { 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

Comments
 (0)