Skip to content

Commit cf44fb7

Browse files
authored
Merge pull request #4 from Indemnity83/fix/unobfuscated-version-support
fix: handle unobfuscated Minecraft versions (26.1+)
2 parents 041311e + b019acc commit cf44fb7

15 files changed

Lines changed: 816 additions & 127 deletions
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { MOJANG_VERSION_MANIFEST_URL } from '../../src/parsers/version-manifest.js';
3+
import type { VersionJson, VersionManifest } from '../../src/types/minecraft.js';
4+
5+
const { mockFetchJson } = vi.hoisted(() => ({
6+
mockFetchJson: vi.fn(),
7+
}));
8+
9+
vi.mock('../../src/downloaders/http-client.js', () => ({
10+
downloadFile: vi.fn(),
11+
fetchJson: mockFetchJson,
12+
}));
13+
14+
import { MojangDownloader } from '../../src/downloaders/mojang-downloader.js';
15+
16+
const VERSION_ID = 'unit-test-version';
17+
const VERSION_JSON_URL = 'https://example.com/unit-test-version.json';
18+
19+
const TEST_MANIFEST: VersionManifest = {
20+
latest: {
21+
release: VERSION_ID,
22+
snapshot: VERSION_ID,
23+
},
24+
versions: [
25+
{
26+
id: VERSION_ID,
27+
type: 'release',
28+
url: VERSION_JSON_URL,
29+
time: '2026-01-01T00:00:00+00:00',
30+
releaseTime: '2026-01-01T00:00:00+00:00',
31+
sha1: 'manifest-sha1',
32+
complianceLevel: 1,
33+
},
34+
],
35+
};
36+
37+
const TEST_VERSION_JSON: VersionJson = {
38+
id: VERSION_ID,
39+
type: 'release',
40+
time: '2026-01-01T00:00:00+00:00',
41+
releaseTime: '2026-01-01T00:00:00+00:00',
42+
mainClass: 'net.minecraft.client.main.Main',
43+
downloads: {
44+
client: {
45+
sha1: 'client-sha1',
46+
size: 1,
47+
url: 'https://example.com/client.jar',
48+
},
49+
},
50+
libraries: [],
51+
};
52+
53+
describe('MojangDownloader', () => {
54+
beforeEach(() => {
55+
mockFetchJson.mockReset();
56+
});
57+
58+
it('should cache version JSON fetches per version', async () => {
59+
mockFetchJson.mockImplementation(async (url: string) => {
60+
if (url === MOJANG_VERSION_MANIFEST_URL) {
61+
return TEST_MANIFEST;
62+
}
63+
64+
if (url === VERSION_JSON_URL) {
65+
return TEST_VERSION_JSON;
66+
}
67+
68+
throw new Error(`Unexpected URL: ${url}`);
69+
});
70+
71+
const downloader = new MojangDownloader();
72+
73+
const [first, second] = await Promise.all([
74+
downloader.getVersionJson(VERSION_ID),
75+
downloader.getVersionJson(VERSION_ID),
76+
]);
77+
78+
expect(first).toEqual(TEST_VERSION_JSON);
79+
expect(second).toEqual(TEST_VERSION_JSON);
80+
81+
const requestedUrls = mockFetchJson.mock.calls.map(([url]) => String(url));
82+
expect(requestedUrls.filter((url) => url === MOJANG_VERSION_MANIFEST_URL)).toHaveLength(1);
83+
expect(requestedUrls.filter((url) => url === VERSION_JSON_URL)).toHaveLength(1);
84+
});
85+
86+
it('should clear failed version JSON promises so retries can succeed', async () => {
87+
let versionJsonAttempts = 0;
88+
89+
mockFetchJson.mockImplementation(async (url: string) => {
90+
if (url === MOJANG_VERSION_MANIFEST_URL) {
91+
return TEST_MANIFEST;
92+
}
93+
94+
if (url === VERSION_JSON_URL) {
95+
versionJsonAttempts += 1;
96+
if (versionJsonAttempts === 1) {
97+
throw new Error('Transient version JSON failure');
98+
}
99+
100+
return TEST_VERSION_JSON;
101+
}
102+
103+
throw new Error(`Unexpected URL: ${url}`);
104+
});
105+
106+
const downloader = new MojangDownloader();
107+
108+
await expect(downloader.getVersionJson(VERSION_ID)).rejects.toThrow(
109+
'Transient version JSON failure',
110+
);
111+
await expect(downloader.getVersionJson(VERSION_ID)).resolves.toEqual(TEST_VERSION_JSON);
112+
113+
const requestedUrls = mockFetchJson.mock.calls.map(([url]) => String(url));
114+
expect(requestedUrls.filter((url) => url === MOJANG_VERSION_MANIFEST_URL)).toHaveLength(1);
115+
expect(requestedUrls.filter((url) => url === VERSION_JSON_URL)).toHaveLength(2);
116+
});
117+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import type { TinyRemapper } from '../../src/java/tiny-remapper.js';
3+
import type { MappingService } from '../../src/services/mapping-service.js';
4+
import { RemapService } from '../../src/services/remap-service.js';
5+
import type { VersionManager } from '../../src/services/version-manager.js';
6+
7+
describe('RemapService (unit)', () => {
8+
it('should fail before downloading JAR for unsupported mappings on unobfuscated versions', async () => {
9+
const remapService = new RemapService();
10+
11+
const isVersionUnobfuscated = vi.fn().mockResolvedValue(true);
12+
const getVersionJar = vi.fn().mockResolvedValue('raw-client.jar');
13+
14+
Reflect.set(remapService, 'versionManager', {
15+
isVersionUnobfuscated,
16+
getVersionJar,
17+
} as Pick<VersionManager, 'isVersionUnobfuscated' | 'getVersionJar'>);
18+
19+
await expect(remapService.getRemappedJar('__vitest_unobf_yarn__', 'yarn')).rejects.toThrow(
20+
/yarn mappings are not supported for unobfuscated/i,
21+
);
22+
23+
expect(isVersionUnobfuscated).toHaveBeenCalledWith('__vitest_unobf_yarn__');
24+
expect(getVersionJar).not.toHaveBeenCalled();
25+
});
26+
27+
it('should return the raw client JAR for unobfuscated mojmap requests without remapping', async () => {
28+
const remapService = new RemapService();
29+
30+
const isVersionUnobfuscated = vi.fn().mockResolvedValue(true);
31+
const getVersionJar = vi.fn().mockResolvedValue('raw-client.jar');
32+
const getMappings = vi.fn();
33+
const remap = vi.fn();
34+
35+
Reflect.set(remapService, 'versionManager', {
36+
isVersionUnobfuscated,
37+
getVersionJar,
38+
} as Pick<VersionManager, 'isVersionUnobfuscated' | 'getVersionJar'>);
39+
Reflect.set(remapService, 'mappingService', { getMappings } as Pick<
40+
MappingService,
41+
'getMappings'
42+
>);
43+
Reflect.set(remapService, 'tinyRemapper', { remap } as Pick<TinyRemapper, 'remap'>);
44+
45+
const result = await remapService.getRemappedJar('__vitest_unobf_mojmap__', 'mojmap');
46+
47+
expect(result).toBe('raw-client.jar');
48+
expect(getVersionJar).toHaveBeenCalledTimes(1);
49+
expect(getMappings).not.toHaveBeenCalled();
50+
expect(remap).not.toHaveBeenCalled();
51+
});
52+
53+
it('should continue remapping normally for obfuscated versions', async () => {
54+
const remapService = new RemapService();
55+
56+
const isVersionUnobfuscated = vi.fn().mockResolvedValue(false);
57+
const getVersionJar = vi.fn().mockResolvedValue('input.jar');
58+
const getMappings = vi.fn().mockResolvedValue('intermediary.tiny');
59+
const remap = vi.fn().mockResolvedValue(undefined);
60+
61+
Reflect.set(remapService, 'versionManager', {
62+
isVersionUnobfuscated,
63+
getVersionJar,
64+
} as Pick<VersionManager, 'isVersionUnobfuscated' | 'getVersionJar'>);
65+
Reflect.set(remapService, 'mappingService', { getMappings } as Pick<
66+
MappingService,
67+
'getMappings'
68+
>);
69+
Reflect.set(remapService, 'tinyRemapper', { remap } as Pick<TinyRemapper, 'remap'>);
70+
71+
const result = await remapService.getRemappedJar('__vitest_obf_intermediary__', 'intermediary');
72+
73+
expect(isVersionUnobfuscated).toHaveBeenCalledWith('__vitest_obf_intermediary__');
74+
expect(getVersionJar).toHaveBeenCalledTimes(1);
75+
expect(getMappings).toHaveBeenCalledWith('__vitest_obf_intermediary__', 'intermediary');
76+
expect(remap).toHaveBeenCalledTimes(1);
77+
expect(result).toContain('__vitest_obf_intermediary__');
78+
expect(result).toContain('intermediary');
79+
});
80+
});

__tests__/core/remap-service.test.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { getCacheManager } from '../../src/cache/cache-manager.js';
55
import { verifyJavaVersion } from '../../src/java/java-process.js';
66
import { getDecompileService } from '../../src/services/decompile-service.js';
77
import { getRemapService } from '../../src/services/remap-service.js';
8-
import { TEST_MAPPING, TEST_VERSION } from '../test-constants.js';
8+
import { TEST_MAPPING, TEST_VERSION, UNOBFUSCATED_TEST_VERSION } from '../test-constants.js';
99

1010
/**
1111
* JAR Remapping Tests
@@ -279,7 +279,7 @@ describe('JAR Remapping', () => {
279279
const source = await decompileService.getClassSource(
280280
TEST_VERSION,
281281
'net.minecraft.world.entity.Entity',
282-
'mojmap'
282+
'mojmap',
283283
);
284284

285285
expect(source).toBeDefined();
@@ -301,7 +301,7 @@ describe('JAR Remapping', () => {
301301
const source = await decompileService.getClassSource(
302302
TEST_VERSION,
303303
'net.minecraft.world.entity.Entity',
304-
'mojmap'
304+
'mojmap',
305305
);
306306

307307
expect(source).toBeDefined();
@@ -318,7 +318,7 @@ describe('JAR Remapping', () => {
318318
const source = await decompileService.getClassSource(
319319
TEST_VERSION,
320320
'net.minecraft.server.MinecraftServer',
321-
'mojmap'
321+
'mojmap',
322322
);
323323

324324
expect(source).toBeDefined();
@@ -342,7 +342,7 @@ describe('JAR Remapping', () => {
342342
const source = await decompileService.getClassSource(
343343
TEST_VERSION,
344344
'net.minecraft.entity.Entity',
345-
'yarn'
345+
'yarn',
346346
);
347347

348348
expect(source).toBeDefined();
@@ -361,7 +361,7 @@ describe('JAR Remapping', () => {
361361
const source = await decompileService.getClassSource(
362362
TEST_VERSION,
363363
'net.minecraft.entity.Entity',
364-
'yarn'
364+
'yarn',
365365
);
366366

367367
expect(source).toBeDefined();
@@ -374,4 +374,30 @@ describe('JAR Remapping', () => {
374374
expect(intermediaryFieldCount).toBeLessThan(20);
375375
}, 60000);
376376
});
377+
378+
describe('Unobfuscated version handling', () => {
379+
// 26.1+ snapshots ship without obfuscation - no intermediary or Yarn mappings exist.
380+
it('should throw a clear error when requesting yarn mappings for an unobfuscated version', async () => {
381+
const remapService = getRemapService();
382+
await expect(remapService.getRemappedJar(UNOBFUSCATED_TEST_VERSION, 'yarn')).rejects.toThrow(
383+
/yarn mappings are not supported for unobfuscated/i,
384+
);
385+
}, 60000); // network call to fetch version JSON on first run
386+
387+
it('should throw a clear error when requesting intermediary mappings for an unobfuscated version', async () => {
388+
const remapService = getRemapService();
389+
await expect(
390+
remapService.getRemappedJar(UNOBFUSCATED_TEST_VERSION, 'intermediary'),
391+
).rejects.toThrow(/intermediary mappings are not supported for unobfuscated/i);
392+
}, 60000);
393+
394+
it('should return the raw client JAR for mojmap on an unobfuscated version', async () => {
395+
const remapService = getRemapService();
396+
const jarPath = await remapService.getRemappedJar(UNOBFUSCATED_TEST_VERSION, 'mojmap');
397+
expect(jarPath).toBeDefined();
398+
expect(existsSync(jarPath)).toBe(true);
399+
// The returned path is the raw client JAR, not a remapped copy
400+
expect(jarPath).not.toContain('remapped');
401+
}, 60000);
402+
});
377403
});

__tests__/core/version-manager.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from 'vitest';
22
import { MojangDownloader } from '../../src/downloaders/mojang-downloader.js';
33
import { getVersionManager } from '../../src/services/version-manager.js';
4-
import { TEST_VERSION } from '../test-constants.js';
4+
import { TEST_VERSION, UNOBFUSCATED_TEST_VERSION } from '../test-constants.js';
55

66
/**
77
* Version Management Tests
@@ -36,4 +36,39 @@ describe('Version Management', () => {
3636

3737
expect(exists).toBe(false);
3838
}, 30000);
39+
40+
describe('Unobfuscated version detection', () => {
41+
it('should return false for obfuscated versions', async () => {
42+
const versionManager = getVersionManager();
43+
// TEST_VERSION (1.21.11) is an obfuscated release - client_mappings present
44+
const result = await versionManager.isVersionUnobfuscated(TEST_VERSION);
45+
expect(result).toBe(false);
46+
}, 30000);
47+
48+
it('should return false for legacy obfuscated versions without client_mappings metadata', async () => {
49+
const versionManager = getVersionManager();
50+
// Early 1.14.x versions can omit client_mappings while still being obfuscated.
51+
const result = await versionManager.isVersionUnobfuscated('1.14.3');
52+
expect(result).toBe(false);
53+
}, 30000);
54+
55+
it('should return true for unobfuscated versions (26.1+)', async () => {
56+
const versionManager = getVersionManager();
57+
// 26.1 snapshots ship without obfuscation - no client_mappings in version JSON
58+
const result = await versionManager.isVersionUnobfuscated(UNOBFUSCATED_TEST_VERSION);
59+
expect(result).toBe(true);
60+
}, 30000);
61+
62+
it('should return true for first unobfuscated boundary version (26.1-snapshot-1)', async () => {
63+
const versionManager = getVersionManager();
64+
const result = await versionManager.isVersionUnobfuscated('26.1-snapshot-1');
65+
expect(result).toBe(true);
66+
}, 30000);
67+
68+
it('should return true for newer unobfuscated snapshot versions (26.1-snapshot-9)', async () => {
69+
const versionManager = getVersionManager();
70+
const result = await versionManager.isVersionUnobfuscated('26.1-snapshot-9');
71+
expect(result).toBe(true);
72+
}, 30000);
73+
});
3974
});

0 commit comments

Comments
 (0)