@adhisang/minecraft-modding-mcp 4.2.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +6 -4
  3. package/dist/access-transformer-parser.d.ts +2 -1
  4. package/dist/access-transformer-parser.js +10 -1
  5. package/dist/build-suggested-call.js +31 -10
  6. package/dist/cache-registry.d.ts +8 -0
  7. package/dist/cache-registry.js +99 -6
  8. package/dist/config.d.ts +1 -0
  9. package/dist/config.js +4 -0
  10. package/dist/entry-tools/analyze-mod-service.d.ts +24 -24
  11. package/dist/entry-tools/analyze-symbol-service.d.ts +57 -22
  12. package/dist/entry-tools/analyze-symbol-service.js +124 -28
  13. package/dist/entry-tools/batch-class-members-service.d.ts +3 -1
  14. package/dist/entry-tools/batch-class-members-service.js +6 -6
  15. package/dist/entry-tools/batch-class-source-service.d.ts +3 -1
  16. package/dist/entry-tools/batch-class-source-service.js +5 -6
  17. package/dist/entry-tools/batch-mappings-service.d.ts +3 -1
  18. package/dist/entry-tools/batch-mappings-service.js +4 -5
  19. package/dist/entry-tools/batch-symbol-exists-service.d.ts +3 -1
  20. package/dist/entry-tools/batch-symbol-exists-service.js +4 -5
  21. package/dist/entry-tools/compare-minecraft-service.d.ts +10 -10
  22. package/dist/entry-tools/entry-tool-schema.d.ts +1 -1
  23. package/dist/entry-tools/inspect-minecraft/handlers/artifact.js +1 -3
  24. package/dist/entry-tools/inspect-minecraft/handlers/class-members.js +2 -2
  25. package/dist/entry-tools/inspect-minecraft/handlers/class-overview.js +0 -5
  26. package/dist/entry-tools/inspect-minecraft/handlers/class-source.js +0 -1
  27. package/dist/entry-tools/inspect-minecraft/handlers/file.js +0 -1
  28. package/dist/entry-tools/inspect-minecraft/handlers/list-files.js +0 -1
  29. package/dist/entry-tools/inspect-minecraft/handlers/search.d.ts +8 -0
  30. package/dist/entry-tools/inspect-minecraft/handlers/search.js +13 -2
  31. package/dist/entry-tools/inspect-minecraft/handlers/versions.d.ts +1 -1
  32. package/dist/entry-tools/inspect-minecraft/handlers/versions.js +1 -1
  33. package/dist/entry-tools/inspect-minecraft/internal.d.ts +90 -89
  34. package/dist/entry-tools/inspect-minecraft/internal.js +30 -14
  35. package/dist/entry-tools/inspect-minecraft-service.d.ts +218 -218
  36. package/dist/entry-tools/inspect-minecraft-service.js +1 -1
  37. package/dist/entry-tools/manage-cache-service.d.ts +4 -4
  38. package/dist/entry-tools/response-contract.d.ts +1 -1
  39. package/dist/entry-tools/response-contract.js +1 -0
  40. package/dist/entry-tools/validate-project/cases/mixin.js +2 -1
  41. package/dist/entry-tools/validate-project/cases/project-summary.js +5 -3
  42. package/dist/entry-tools/validate-project-service.d.ts +42 -36
  43. package/dist/entry-tools/validate-project-service.js +3 -1
  44. package/dist/entry-tools/verify-mixin-target-service.d.ts +30 -0
  45. package/dist/entry-tools/verify-mixin-target-service.js +113 -22
  46. package/dist/error-mapping.d.ts +51 -0
  47. package/dist/error-mapping.js +157 -2
  48. package/dist/errors.js +4 -1
  49. package/dist/index.js +138 -71
  50. package/dist/java-process.d.ts +9 -0
  51. package/dist/java-process.js +11 -5
  52. package/dist/mapping/internal-types.d.ts +7 -0
  53. package/dist/mapping/lookup.d.ts +1 -1
  54. package/dist/mapping/lookup.js +7 -1
  55. package/dist/mapping/types.d.ts +6 -0
  56. package/dist/mapping-service.d.ts +26 -2
  57. package/dist/mapping-service.js +178 -36
  58. package/dist/mcp-helpers.d.ts +1 -0
  59. package/dist/mcp-helpers.js +25 -3
  60. package/dist/minecraft-explorer-service.js +24 -11
  61. package/dist/mixin/access-validators.js +33 -1
  62. package/dist/mixin/annotation-validators.js +53 -7
  63. package/dist/mixin/parsed-validator.js +2 -2
  64. package/dist/mixin-parser.js +48 -4
  65. package/dist/mod-decompile-service.js +2 -1
  66. package/dist/mod-remap-service.d.ts +20 -1
  67. package/dist/mod-remap-service.js +95 -40
  68. package/dist/nbt/java-nbt-codec.js +87 -2
  69. package/dist/observability.d.ts +6 -0
  70. package/dist/observability.js +10 -0
  71. package/dist/page-cursor.d.ts +17 -0
  72. package/dist/page-cursor.js +0 -0
  73. package/dist/registry-service.js +25 -1
  74. package/dist/resources.js +45 -9
  75. package/dist/response-utils.d.ts +29 -4
  76. package/dist/response-utils.js +95 -5
  77. package/dist/source/access-validate.js +12 -2
  78. package/dist/source/artifact-resolver.js +1 -1
  79. package/dist/source/cache-metrics.js +2 -2
  80. package/dist/source/class-source/members-builder.d.ts +44 -1
  81. package/dist/source/class-source/members-builder.js +51 -4
  82. package/dist/source/class-source/snippet-builder.d.ts +13 -0
  83. package/dist/source/class-source/snippet-builder.js +48 -4
  84. package/dist/source/class-source-helpers.js +67 -11
  85. package/dist/source/class-source.js +97 -20
  86. package/dist/source/indexer.d.ts +31 -6
  87. package/dist/source/indexer.js +95 -25
  88. package/dist/source/search.d.ts +1 -1
  89. package/dist/source/search.js +94 -22
  90. package/dist/source/validate-mixin.d.ts +2 -1
  91. package/dist/source/validate-mixin.js +45 -4
  92. package/dist/source/workspace-target.js +15 -9
  93. package/dist/source-jar-reader.d.ts +54 -1
  94. package/dist/source-jar-reader.js +121 -10
  95. package/dist/source-service.d.ts +28 -7
  96. package/dist/storage/files-repo.d.ts +12 -0
  97. package/dist/storage/files-repo.js +24 -0
  98. package/dist/storage/symbols-repo.d.ts +6 -0
  99. package/dist/storage/symbols-repo.js +6 -0
  100. package/dist/symbols/symbol-extractor.js +35 -6
  101. package/dist/text-truncate.d.ts +7 -0
  102. package/dist/text-truncate.js +21 -0
  103. package/dist/tiny-remapper-resolver.js +10 -0
  104. package/dist/tiny-remapper-service.js +3 -3
  105. package/dist/tool-contract-manifest.js +2 -2
  106. package/dist/tool-execution-gate.js +12 -1
  107. package/dist/tool-guidance.d.ts +4 -1
  108. package/dist/tool-guidance.js +37 -12
  109. package/dist/tool-schemas.d.ts +559 -494
  110. package/dist/tool-schemas.js +99 -63
  111. package/dist/types.d.ts +1 -0
  112. package/dist/version-diff-service.js +5 -0
  113. package/dist/version-service.d.ts +3 -0
  114. package/dist/version-service.js +18 -2
  115. package/dist/warning-details.d.ts +18 -0
  116. package/dist/warning-details.js +63 -0
  117. package/docs/README-ja.md +2 -2
  118. package/docs/examples.md +26 -2
  119. package/docs/tool-reference.md +23 -17
  120. package/package.json +1 -1
@@ -43,13 +43,59 @@ export function validateInjection(inj, targetMembers, targetNames, issues, resol
43
43
  });
44
44
  }
45
45
  else {
46
- resolvedMembers.push({
47
- annotation: `@${inj.annotation}`,
48
- name: methodName,
49
- line: inj.line,
50
- resolvedTo: `${targetName}#${methodName}`,
51
- status: "resolved"
52
- });
46
+ // Name matched. If the reference carries a JVM descriptor, require an
47
+ // overload to match it; otherwise a wrong-signature target (e.g. tick(D)V
48
+ // when only tick(I)V exists) is silently reported as resolved.
49
+ const refDescriptor = extractMethodDescriptor(inj.method);
50
+ const sameNameMembers = [...members.constructors, ...members.methods].filter((member) => member.name === methodName);
51
+ if (refDescriptor &&
52
+ sameNameMembers.length > 0 &&
53
+ !sameNameMembers.some((member) => member.jvmDescriptor === refDescriptor)) {
54
+ const available = [...new Set(sameNameMembers.map((member) => member.jvmDescriptor))];
55
+ const isRemapFailed = remapFailedMembers?.get(targetName)?.has(methodName);
56
+ const isSigFailed = signatureFailedTargets?.has(targetName);
57
+ // Only a healthy mapping lets us trust that the user-written descriptor is
58
+ // byte-equal to the runtime descriptor; otherwise downgrade to a warning.
59
+ const mappingDegraded = Boolean(isRemapFailed) || Boolean(isSigFailed) || healthReport?.overallHealthy === false;
60
+ const resolutionPath = isRemapFailed
61
+ ? "member-remap-failed"
62
+ : isSigFailed
63
+ ? "source-signature-unavailable"
64
+ : undefined;
65
+ const issueConfidence = mappingDegraded
66
+ ? "uncertain"
67
+ : confidence ?? "definite";
68
+ issues.push({
69
+ severity: mappingDegraded ? "warning" : "error",
70
+ kind: "descriptor-mismatch",
71
+ annotation: `@${inj.annotation}`,
72
+ target: `${targetName}#${inj.method}`,
73
+ message: `Method "${methodName}" exists in "${targetName}" but no overload matches descriptor ${refDescriptor} (available: ${available.join(", ")}).${mappingDegraded ? " (mapping degraded; may be a remap artifact)" : ""}`,
74
+ suggestions: available.length > 0 ? available : undefined,
75
+ line: inj.line,
76
+ confidence: issueConfidence,
77
+ confidenceReason: mappingDegraded
78
+ ? `Member remap/signature degraded; descriptor mismatch may be a tooling artifact, not a true signature error.`
79
+ : confidenceReason,
80
+ resolutionPath,
81
+ falsePositiveRisk: computeFalsePositiveRisk(healthReport, resolutionPath, issueConfidence)
82
+ });
83
+ resolvedMembers.push({
84
+ annotation: `@${inj.annotation}`,
85
+ name: methodName,
86
+ line: inj.line,
87
+ status: "not-found"
88
+ });
89
+ }
90
+ else {
91
+ resolvedMembers.push({
92
+ annotation: `@${inj.annotation}`,
93
+ name: methodName,
94
+ line: inj.line,
95
+ resolvedTo: `${targetName}#${methodName}`,
96
+ status: "resolved"
97
+ });
98
+ }
53
99
  }
54
100
  }
55
101
  }
@@ -209,7 +209,7 @@ export function validateParsedMixin(parsed, targetMembers, warnings, provenance,
209
209
  if (version) {
210
210
  assignSuggested(issue, "get-class-source", {
211
211
  className: issue.target,
212
- target: { type: "resolve", kind: "version", value: version },
212
+ target: { kind: "version", value: version },
213
213
  ...(mapping ? { mapping } : {}),
214
214
  mode: "metadata",
215
215
  ...classSourceContext
@@ -236,7 +236,7 @@ export function validateParsedMixin(parsed, targetMembers, warnings, provenance,
236
236
  if (version) {
237
237
  assignSuggested(issue, "get-class-source", {
238
238
  className,
239
- target: { type: "resolve", kind: "version", value: version },
239
+ target: { kind: "version", value: version },
240
240
  ...(mapping ? { mapping } : {}),
241
241
  mode: "metadata",
242
242
  ...classSourceContext
@@ -61,9 +61,38 @@ function collectMultilineAnnotation(lines, startIndex) {
61
61
  * Lines with inline annotation + declaration (e.g. `@Nullable private int x;`)
62
62
  * are treated as declaration lines (not skipped).
63
63
  */
64
+ function isUpperCaseLetter(ch) {
65
+ return ch !== ch.toLowerCase() && ch === ch.toUpperCase();
66
+ }
64
67
  function skipAnnotations(lines, startIndex) {
65
68
  let idx = startIndex;
66
- while (idx < lines.length && /^\s*@/.test(lines[idx])) {
69
+ while (idx < lines.length) {
70
+ const current = lines[idx];
71
+ // Skip blank lines and comment-only lines that sit between an annotation and the
72
+ // member declaration; otherwise the declaration line is missed and the member dropped.
73
+ if (/^\s*$/.test(current) || /^\s*\/\//.test(current)) {
74
+ idx++;
75
+ continue;
76
+ }
77
+ if (/^\s*\/\*/.test(current)) {
78
+ if (current.includes("*/")) {
79
+ const afterComment = current.slice(current.indexOf("*/") + 2).trim();
80
+ if (afterComment === "") {
81
+ idx++;
82
+ continue;
83
+ }
84
+ break; // declaration text follows an inline block comment
85
+ }
86
+ idx++;
87
+ while (idx < lines.length && !lines[idx].includes("*/")) {
88
+ idx++;
89
+ }
90
+ idx++; // move past the closing "*/" line
91
+ continue;
92
+ }
93
+ if (!/^\s*@/.test(current)) {
94
+ break;
95
+ }
67
96
  if (lines[idx].includes("(")) {
68
97
  // Check if parentheses are unbalanced (multi-line annotation) — always skip
69
98
  let depth = 0;
@@ -106,18 +135,33 @@ const INLINE_ANNOTATION_RE = /\s*@[\w$.]+(?:\([^)]*\))?\s*/g;
106
135
  function stripInlineAnnotations(line) {
107
136
  return line.replace(INLINE_ANNOTATION_RE, " ").trim();
108
137
  }
138
+ /**
139
+ * JavaBeans `Introspector.decapitalize`, which SpongePowered Mixin's AccessorInfo
140
+ * uses for inflection: leave the name unchanged when its first two characters are
141
+ * both upper case (getURL -> URL, getNBT -> NBT), otherwise lower-case the first
142
+ * character (getId -> id, getHealth -> health).
143
+ */
144
+ function decapitalize(name) {
145
+ if (name.length === 0) {
146
+ return name;
147
+ }
148
+ if (name.length > 1 && isUpperCaseLetter(name.charAt(1)) && isUpperCaseLetter(name.charAt(0))) {
149
+ return name;
150
+ }
151
+ return name.charAt(0).toLowerCase() + name.slice(1);
152
+ }
109
153
  function inferAccessorTarget(methodName) {
110
154
  const getterMatch = GETTER_PREFIX_RE.exec(methodName);
111
155
  if (getterMatch) {
112
- return getterMatch[1].charAt(0).toLowerCase() + getterMatch[1].slice(1);
156
+ return decapitalize(getterMatch[1]);
113
157
  }
114
158
  const setterMatch = SETTER_PREFIX_RE.exec(methodName);
115
159
  if (setterMatch) {
116
- return setterMatch[1].charAt(0).toLowerCase() + setterMatch[1].slice(1);
160
+ return decapitalize(setterMatch[1]);
117
161
  }
118
162
  const invokerMatch = INVOKER_PREFIX_RE.exec(methodName);
119
163
  if (invokerMatch) {
120
- return invokerMatch[1].charAt(0).toLowerCase() + invokerMatch[1].slice(1);
164
+ return decapitalize(invokerMatch[1]);
121
165
  }
122
166
  return methodName;
123
167
  }
@@ -7,6 +7,7 @@ import { decompileBinaryJar } from "./decompiler/vineflower.js";
7
7
  import { resolveVineflowerJar } from "./vineflower-resolver.js";
8
8
  import { analyzeModJar } from "./mod-analyzer.js";
9
9
  import { validateAndNormalizeJarPath } from "./path-resolver.js";
10
+ import { sliceToMaxCharsSafe } from "./text-truncate.js";
10
11
  const DECOMPILE_TIMEOUT_MS = 300_000;
11
12
  function modDecompileCacheKey(jarPath) {
12
13
  return createHash("sha256").update(jarPath).digest("hex");
@@ -122,7 +123,7 @@ export class ModDecompileService {
122
123
  }
123
124
  // Apply maxChars truncation
124
125
  if (input.maxChars != null && input.maxChars > 0 && content.length > input.maxChars) {
125
- content = content.slice(0, input.maxChars);
126
+ content = sliceToMaxCharsSafe(content, input.maxChars);
126
127
  charsTruncated = true;
127
128
  truncated = true;
128
129
  }
@@ -1,9 +1,18 @@
1
+ import { resolveTinyMappingFile } from "./mapping-service.js";
2
+ import { resolveMojangTinyFile } from "./mojang-tiny-mapping-service.js";
3
+ import { remapJar } from "./tiny-remapper-service.js";
4
+ import { resolveTinyRemapperJar } from "./tiny-remapper-resolver.js";
1
5
  import type { Config } from "./types.js";
2
6
  export interface ModRemapInput {
3
7
  inputJar: string;
4
8
  outputJar?: string;
5
9
  mcVersion?: string;
6
10
  targetMapping: "yarn" | "mojang";
11
+ /**
12
+ * Skip the remap cache and force re-resolution of the newest yarn build
13
+ * (bypasses both the output-jar cache hit and the yarn metadata TTL).
14
+ */
15
+ forceRemap?: boolean;
7
16
  }
8
17
  export interface ModRemapResult {
9
18
  outputJar: string;
@@ -14,4 +23,14 @@ export interface ModRemapResult {
14
23
  durationMs: number;
15
24
  warnings: string[];
16
25
  }
17
- export declare function remapModJar(input: ModRemapInput, config: Config): Promise<ModRemapResult>;
26
+ /**
27
+ * Injectable external dependencies, used by tests to drive the remap pipeline
28
+ * without spawning Java or downloading mapping jars. Production callers omit it.
29
+ */
30
+ export interface RemapModJarDeps {
31
+ remapJar?: typeof remapJar;
32
+ resolveTinyMappingFile?: typeof resolveTinyMappingFile;
33
+ resolveMojangTinyFile?: typeof resolveMojangTinyFile;
34
+ resolveTinyRemapperJar?: typeof resolveTinyRemapperJar;
35
+ }
36
+ export declare function remapModJar(input: ModRemapInput, config: Config, deps?: RemapModJarDeps): Promise<ModRemapResult>;
@@ -11,6 +11,13 @@ import { normalizePathForHost } from "./path-converter.js";
11
11
  import { detectFabricLikeInputNamespace } from "./source-jar-reader.js";
12
12
  import { remapJar } from "./tiny-remapper-service.js";
13
13
  import { resolveTinyRemapperJar } from "./tiny-remapper-resolver.js";
14
+ // Bumped whenever the remap pipeline changes in a way that makes previously
15
+ // cached output jars wrong. The v1 pipeline mis-mapped intermediary jars to
16
+ // mojang using an obfuscated<->mojang tiny (no intermediary namespace), so any
17
+ // cached mojang output is invalid and must not be reused. v3 adds the resolved
18
+ // mapping identity (yarn build coordinate) to the cache key so a newer yarn
19
+ // build no longer serves the stale jar; the bump invalidates v2 outputs once.
20
+ const REMAP_PIPELINE_VERSION = "v3";
14
21
  function normalizeTargetNamespace(target) {
15
22
  return target === "yarn" ? "yarn" : "mojang";
16
23
  }
@@ -58,14 +65,18 @@ function copyJarToDestination(sourceJar, destinationJar) {
58
65
  mkdirSync(dirname(destinationJar), { recursive: true });
59
66
  copyFileSync(sourceJar, destinationJar);
60
67
  }
61
- function buildCacheKey(inputJar, fromNamespace, targetNamespace, mcVersion) {
68
+ function buildCacheKey(inputJar, fromNamespace, targetNamespace, mcVersion, mappingIdentity) {
62
69
  const stat = statSync(inputJar, { throwIfNoEntry: false });
63
70
  const signature = stat ? `${stat.mtimeMs}:${stat.size}` : "unknown";
64
71
  return createHash("sha256")
65
- .update(`${inputJar}|${signature}|${fromNamespace}|${targetNamespace}|${mcVersion}`)
72
+ .update(`${REMAP_PIPELINE_VERSION}|${inputJar}|${signature}|${fromNamespace}|${targetNamespace}|${mcVersion}|${mappingIdentity}`)
66
73
  .digest("hex");
67
74
  }
68
- export async function remapModJar(input, config) {
75
+ export async function remapModJar(input, config, deps = {}) {
76
+ const remapJarFn = deps.remapJar ?? remapJar;
77
+ const resolveTinyMappingFileFn = deps.resolveTinyMappingFile ?? resolveTinyMappingFile;
78
+ const resolveMojangTinyFileFn = deps.resolveMojangTinyFile ?? resolveMojangTinyFile;
79
+ const resolveTinyRemapperJarFn = deps.resolveTinyRemapperJar ?? resolveTinyRemapperJar;
69
80
  const startedAt = Date.now();
70
81
  const warnings = [];
71
82
  // 1. Normalize input JAR path
@@ -111,12 +122,33 @@ export async function remapModJar(input, config) {
111
122
  });
112
123
  }
113
124
  const outputJar = resolveOutputJarPath(input, normalizedInput, analysis.modId, analysis.modVersion);
114
- // 4. Check cache after mapping context is known
115
- const cacheKey = buildCacheKey(normalizedInput, fromNamespace, resolvedTargetNamespace, mcVersion);
125
+ // 4. Resolve the mapping identity BEFORE the cache key so the key reflects
126
+ // which mappings produced the jar. Yarn builds are mutable (Fabric publishes
127
+ // new builds per MC version), so the resolved yarn coordinate is the identity;
128
+ // mojang is immutable per release so its version-keyed path string suffices.
129
+ // The yarn resolution is TTL-fenced (see resolveTinyMappingFile), so on the
130
+ // common warm path this is zero-network and stays off the cache-hit fast path.
131
+ let mappingIdentity;
132
+ let resolvedYarnTiny;
133
+ if (fromNamespace === resolvedTargetNamespace) {
134
+ // No remap will run (input already in target namespace); the output is a
135
+ // copy of the input, so no mapping identity is involved.
136
+ mappingIdentity = `copy:${resolvedTargetNamespace}`;
137
+ }
138
+ else if (resolvedTargetNamespace === "yarn") {
139
+ resolvedYarnTiny = await resolveTinyMappingFileFn(mcVersion, "yarn", config.cacheDir, undefined, {
140
+ forceRefresh: input.forceRemap
141
+ });
142
+ mappingIdentity = resolvedYarnTiny.coordinate ?? resolvedYarnTiny.path;
143
+ }
144
+ else {
145
+ mappingIdentity = `${mcVersion}-mojang-merged.tiny`;
146
+ }
147
+ const cacheKey = buildCacheKey(normalizedInput, fromNamespace, resolvedTargetNamespace, mcVersion, mappingIdentity);
116
148
  const cacheDir = join(config.cacheDir, "remapped-mods");
117
149
  mkdirSync(cacheDir, { recursive: true });
118
150
  const cachedOutput = join(cacheDir, `${cacheKey}.jar`);
119
- if (existsSync(cachedOutput)) {
151
+ if (!input.forceRemap && existsSync(cachedOutput)) {
120
152
  const cacheHitOutputJar = input.outputJar
121
153
  ? outputJar
122
154
  : cachedOutput;
@@ -161,51 +193,74 @@ export async function remapModJar(input, config) {
161
193
  });
162
194
  }
163
195
  // 5. Resolve tiny-remapper
164
- const tinyRemapperJar = await resolveTinyRemapperJar(config.cacheDir, config.tinyRemapperJarPath);
165
- // 6. Resolve mapping file and remap
166
- let mappingsFile;
167
- let toNamespace;
196
+ const tinyRemapperJar = await resolveTinyRemapperJarFn(config.cacheDir, config.tinyRemapperJarPath);
197
+ const passes = [];
168
198
  if (resolvedTargetNamespace === "yarn") {
169
- mappingsFile = await resolveTinyMappingFile(mcVersion, "yarn", config.cacheDir);
170
- toNamespace = "named";
199
+ // The Fabric yarn v2 tiny declares both intermediary and named, so a single
200
+ // intermediary -> named pass is sufficient.
201
+ // Reuse the coordinate resolved for the cache key (already TTL-fenced).
202
+ const yarnTiny = resolvedYarnTiny ?? await resolveTinyMappingFileFn(mcVersion, "yarn", config.cacheDir, undefined, {
203
+ forceRefresh: input.forceRemap
204
+ });
205
+ passes.push({ mappingsFile: yarnTiny.path, fromNamespace, toNamespace: "named" });
171
206
  }
172
207
  else {
173
- const mojangTiny = await resolveMojangTinyFile(mcVersion, config);
174
- mappingsFile = mojangTiny.path;
175
- toNamespace = "mojang";
208
+ // Mojang target. The merged mojang tiny only bridges obfuscated <-> mojang,
209
+ // but a Fabric/Quilt jar is intermediary-named, so a direct intermediary ->
210
+ // mojang remap finds no matching keys and silently no-ops. Remap in two passes:
211
+ // pass 1: intermediary -> official (= obfuscated) via the Fabric intermediary v2 tiny
212
+ // pass 2: obfuscated -> mojang via the merged mojang tiny
213
+ const mojangTiny = await resolveMojangTinyFileFn(mcVersion, config);
176
214
  warnings.push(...mojangTiny.warnings);
215
+ if (fromNamespace === "intermediary") {
216
+ const intermediaryTiny = await resolveTinyMappingFileFn(mcVersion, "intermediary", config.cacheDir);
217
+ passes.push({ mappingsFile: intermediaryTiny.path, fromNamespace: "intermediary", toNamespace: "official" });
218
+ passes.push({ mappingsFile: mojangTiny.path, fromNamespace: "obfuscated", toNamespace: "mojang" });
219
+ }
220
+ else {
221
+ // Defensive: an already-obfuscated input maps directly. (mojang -> mojang is
222
+ // handled earlier by the copy short-circuit, so this is not normally reached.)
223
+ passes.push({ mappingsFile: mojangTiny.path, fromNamespace: "obfuscated", toNamespace: "mojang" });
224
+ }
177
225
  }
178
226
  mkdirSync(dirname(outputJar), { recursive: true });
179
- // 8. Use temporary directory for intermediate work
227
+ // 7. Use a temporary directory for intermediate work
180
228
  const tempDir = join(tmpdir(), `mcp-remap-${cacheKey.slice(0, 12)}`);
181
229
  mkdirSync(tempDir, { recursive: true });
182
230
  try {
183
- const tempOutput = join(tempDir, "remapped.jar");
184
- try {
185
- await remapJar(tinyRemapperJar, {
186
- inputJar: normalizedInput,
187
- outputJar: tempOutput,
188
- mappingsFile,
189
- fromNamespace,
190
- toNamespace,
191
- timeoutMs: config.remapTimeoutMs,
192
- maxMemoryMb: config.remapMaxMemoryMb
193
- });
194
- }
195
- catch (caughtError) {
196
- if (isAppError(caughtError)) {
197
- throw createError({
198
- code: caughtError.code,
199
- message: caughtError.message,
200
- details: {
201
- ...(caughtError.details ?? {}),
202
- fromMapping: fromNamespace,
203
- targetMapping: input.targetMapping,
204
- resolvedTargetNamespace
205
- }
231
+ let passInput = normalizedInput;
232
+ let tempOutput = "";
233
+ for (let passIndex = 0; passIndex < passes.length; passIndex += 1) {
234
+ const pass = passes[passIndex];
235
+ tempOutput = join(tempDir, `pass-${passIndex}.jar`);
236
+ try {
237
+ await remapJarFn(tinyRemapperJar, {
238
+ inputJar: passInput,
239
+ outputJar: tempOutput,
240
+ mappingsFile: pass.mappingsFile,
241
+ fromNamespace: pass.fromNamespace,
242
+ toNamespace: pass.toNamespace,
243
+ timeoutMs: config.remapTimeoutMs,
244
+ maxMemoryMb: config.remapMaxMemoryMb
206
245
  });
207
246
  }
208
- throw caughtError;
247
+ catch (caughtError) {
248
+ if (isAppError(caughtError)) {
249
+ throw createError({
250
+ code: caughtError.code,
251
+ message: caughtError.message,
252
+ details: {
253
+ ...(caughtError.details ?? {}),
254
+ fromMapping: fromNamespace,
255
+ targetMapping: input.targetMapping,
256
+ resolvedTargetNamespace,
257
+ pass: `${pass.fromNamespace}->${pass.toNamespace}`
258
+ }
259
+ });
260
+ }
261
+ throw caughtError;
262
+ }
263
+ passInput = tempOutput;
209
264
  }
210
265
  // Copy to final destination and cache
211
266
  copyFileSync(tempOutput, outputJar);
@@ -37,6 +37,91 @@ function resolveTagName(tagId, pointer) {
37
37
  }
38
38
  return tagName;
39
39
  }
40
+ // Java NBT TAG_String uses MUTF-8 (Mojang's on-disk format matches the JVM
41
+ // `DataInput/OutputStream.{read,write}UTF` contract), which differs from UTF-8
42
+ // for two ranges: `U+0000` encodes as `C0 80`, and supplementary code points
43
+ // (`U+10000`+) encode as two 3-byte sequences for the UTF-16 surrogate pair
44
+ // (6 bytes total). All other characters share the UTF-8 byte sequence.
45
+ function mutf8ByteLength(value) {
46
+ let length = 0;
47
+ for (let i = 0; i < value.length; i += 1) {
48
+ const c = value.charCodeAt(i);
49
+ if (c === 0x0000) {
50
+ length += 2;
51
+ }
52
+ else if (c <= 0x007f) {
53
+ length += 1;
54
+ }
55
+ else if (c <= 0x07ff) {
56
+ length += 2;
57
+ }
58
+ else {
59
+ length += 3;
60
+ }
61
+ }
62
+ return length;
63
+ }
64
+ function encodeMutf8(value) {
65
+ const buffer = Buffer.allocUnsafe(mutf8ByteLength(value));
66
+ let offset = 0;
67
+ for (let i = 0; i < value.length; i += 1) {
68
+ const c = value.charCodeAt(i);
69
+ if (c === 0x0000) {
70
+ buffer[offset++] = 0xc0;
71
+ buffer[offset++] = 0x80;
72
+ }
73
+ else if (c <= 0x007f) {
74
+ buffer[offset++] = c;
75
+ }
76
+ else if (c <= 0x07ff) {
77
+ buffer[offset++] = 0xc0 | (c >> 6);
78
+ buffer[offset++] = 0x80 | (c & 0x3f);
79
+ }
80
+ else {
81
+ buffer[offset++] = 0xe0 | (c >> 12);
82
+ buffer[offset++] = 0x80 | ((c >> 6) & 0x3f);
83
+ buffer[offset++] = 0x80 | (c & 0x3f);
84
+ }
85
+ }
86
+ return buffer;
87
+ }
88
+ function decodeMutf8(buffer, start, end) {
89
+ const codeUnits = [];
90
+ let i = start;
91
+ while (i < end) {
92
+ const b0 = buffer[i++];
93
+ if ((b0 & 0x80) === 0) {
94
+ codeUnits.push(b0);
95
+ }
96
+ else if ((b0 & 0xe0) === 0xc0) {
97
+ if (i >= end) {
98
+ throw parseError("Truncated MUTF-8 2-byte sequence.", { offset: i - 1 });
99
+ }
100
+ const b1 = buffer[i++];
101
+ if ((b1 & 0xc0) !== 0x80) {
102
+ throw parseError("Invalid MUTF-8 continuation byte.", { byte: b1, offset: i - 1 });
103
+ }
104
+ codeUnits.push(((b0 & 0x1f) << 6) | (b1 & 0x3f));
105
+ }
106
+ else if ((b0 & 0xf0) === 0xe0) {
107
+ if (i + 1 >= end) {
108
+ throw parseError("Truncated MUTF-8 3-byte sequence.", { offset: i - 1 });
109
+ }
110
+ const b1 = buffer[i++];
111
+ const b2 = buffer[i++];
112
+ if ((b1 & 0xc0) !== 0x80 || (b2 & 0xc0) !== 0x80) {
113
+ throw parseError("Invalid MUTF-8 continuation byte.", { offset: i - 2 });
114
+ }
115
+ codeUnits.push(((b0 & 0x0f) << 12) | ((b1 & 0x3f) << 6) | (b2 & 0x3f));
116
+ }
117
+ else {
118
+ throw parseError("Invalid MUTF-8 lead byte.", { byte: b0, offset: i - 1 });
119
+ }
120
+ }
121
+ // NBT TAG_String is bounded by uint16 length so codeUnits.length <= 65535,
122
+ // safely within String.fromCharCode's argument cap.
123
+ return String.fromCharCode(...codeUnits);
124
+ }
40
125
  class NbtReader {
41
126
  buffer;
42
127
  offset = 0;
@@ -103,7 +188,7 @@ class NbtReader {
103
188
  readString() {
104
189
  const byteLength = this.readUInt16();
105
190
  this.ensure(byteLength);
106
- const value = this.buffer.toString("utf8", this.offset, this.offset + byteLength);
191
+ const value = decodeMutf8(this.buffer, this.offset, this.offset + byteLength);
107
192
  this.offset += byteLength;
108
193
  return value;
109
194
  }
@@ -154,7 +239,7 @@ class NbtWriter {
154
239
  this.chunks.push(chunk);
155
240
  }
156
241
  writeString(value) {
157
- const encoded = Buffer.from(value, "utf8");
242
+ const encoded = encodeMutf8(value);
158
243
  if (encoded.length > 0xffff) {
159
244
  throw encodeError("NBT string length exceeds uint16.", { byteLength: encoded.length });
160
245
  }
@@ -40,6 +40,8 @@ export interface RuntimeMetricSnapshot {
40
40
  search_rows_scanned: number;
41
41
  search_rows_returned: number;
42
42
  search_indexed_zero_shortcircuit_count: number;
43
+ search_like_prefilter_count: number;
44
+ search_scan_truncated_count: number;
43
45
  reindex_count: number;
44
46
  reindex_skip_count: number;
45
47
  cache_evictions: number;
@@ -74,6 +76,8 @@ export declare class RuntimeMetrics {
74
76
  private searchRowsScanned;
75
77
  private searchRowsReturned;
76
78
  private searchIndexedZeroShortcircuitCount;
79
+ private searchLikePrefilterCount;
80
+ private searchScanTruncatedCount;
77
81
  private reindexCount;
78
82
  private reindexSkipCount;
79
83
  private cacheEvictions;
@@ -102,6 +106,8 @@ export declare class RuntimeMetrics {
102
106
  recordSearchRowsScanned(count: number): void;
103
107
  recordSearchRowsReturned(count: number): void;
104
108
  recordSearchIndexedZeroShortcircuit(): void;
109
+ recordSearchLikePrefilter(): void;
110
+ recordSearchScanTruncated(): void;
105
111
  recordReindex(): void;
106
112
  recordReindexSkip(): void;
107
113
  recordCacheEviction(count?: number): void;
@@ -26,6 +26,8 @@ export class RuntimeMetrics {
26
26
  searchRowsScanned = 0;
27
27
  searchRowsReturned = 0;
28
28
  searchIndexedZeroShortcircuitCount = 0;
29
+ searchLikePrefilterCount = 0;
30
+ searchScanTruncatedCount = 0;
29
31
  reindexCount = 0;
30
32
  reindexSkipCount = 0;
31
33
  cacheEvictions = 0;
@@ -128,6 +130,12 @@ export class RuntimeMetrics {
128
130
  recordSearchIndexedZeroShortcircuit() {
129
131
  this.searchIndexedZeroShortcircuitCount += 1;
130
132
  }
133
+ recordSearchLikePrefilter() {
134
+ this.searchLikePrefilterCount += 1;
135
+ }
136
+ recordSearchScanTruncated() {
137
+ this.searchScanTruncatedCount += 1;
138
+ }
131
139
  recordReindex() {
132
140
  this.reindexCount += 1;
133
141
  }
@@ -192,6 +200,8 @@ export class RuntimeMetrics {
192
200
  search_rows_scanned: this.searchRowsScanned,
193
201
  search_rows_returned: this.searchRowsReturned,
194
202
  search_indexed_zero_shortcircuit_count: this.searchIndexedZeroShortcircuitCount,
203
+ search_like_prefilter_count: this.searchLikePrefilterCount,
204
+ search_scan_truncated_count: this.searchScanTruncatedCount,
195
205
  reindex_count: this.reindexCount,
196
206
  reindex_skip_count: this.reindexSkipCount,
197
207
  cache_evictions: this.cacheEvictions,
@@ -0,0 +1,17 @@
1
+ export type OffsetCursor = {
2
+ offset: number;
3
+ contextKey: string;
4
+ };
5
+ /** Build a stable context key from the query parameters a cursor belongs to. */
6
+ export declare function buildPageContextKey(parts: Array<string | number | boolean | undefined>): string;
7
+ export declare function encodeOffsetCursor(offset: number, contextKey: string): string;
8
+ export declare function decodeOffsetCursor(cursor: string | undefined): OffsetCursor | undefined;
9
+ /**
10
+ * Resolve the starting offset from an incoming cursor. Returns offset 0 when the
11
+ * cursor is absent, malformed, or belongs to a different query context, and
12
+ * reports whether a provided cursor was ignored.
13
+ */
14
+ export declare function resolveCursorOffset(cursor: string | undefined, contextKey: string): {
15
+ offset: number;
16
+ cursorIgnored: boolean;
17
+ };
Binary file
@@ -82,6 +82,25 @@ function parseRegistrySnapshot(raw, version, registryFile) {
82
82
  details: { version, registryFile }
83
83
  });
84
84
  }
85
+ // Every registry value must carry an `entries` object. Without this guard a
86
+ // malformed value slips through and later throws a raw TypeError on `.entries`
87
+ // (registry-service.ts:268/305, version-diff-service.ts), bypassing the
88
+ // corrupt-snapshot regeneration path that keys off REGISTRY_GENERATION_FAILED.
89
+ for (const [registryName, value] of Object.entries(parsed)) {
90
+ const entries = value?.entries;
91
+ if (typeof value !== "object" ||
92
+ value === null ||
93
+ Array.isArray(value) ||
94
+ typeof entries !== "object" ||
95
+ entries === null ||
96
+ Array.isArray(entries)) {
97
+ throw createError({
98
+ code: ERROR_CODES.REGISTRY_GENERATION_FAILED,
99
+ message: `registries.json for version "${version}" has a malformed "${registryName}" registry (missing entries).`,
100
+ details: { version, registryFile, registryName }
101
+ });
102
+ }
103
+ }
85
104
  return parsed;
86
105
  }
87
106
  function runDataGen(serverJarPath, outputDir, version) {
@@ -252,8 +271,13 @@ export class RegistryService {
252
271
  }
253
272
  async loadRegistries(version, warnings) {
254
273
  const cached = this.registryCache.get(version);
255
- if (cached)
274
+ if (cached) {
275
+ // LRU touch: move to most-recently-used so the bound evicts the true
276
+ // coldest entry (Map preserves insertion order).
277
+ this.registryCache.delete(version);
278
+ this.registryCache.set(version, cached);
256
279
  return cached;
280
+ }
257
281
  const registryDir = join(this.config.cacheDir, "registries", version);
258
282
  const cachedRegistries = this.loadExistingRegistries(registryDir, version, warnings);
259
283
  if (cachedRegistries) {