@px-lsp/server 0.1.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 (163) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +334 -0
  3. package/THIRD-PARTY-NOTICES.md +83 -0
  4. package/data/ck3/dataTypes.json +2195 -0
  5. package/data/ck3/data_types/data_types_common.txt +2040 -0
  6. package/data/ck3/data_types/data_types_gui.txt +5264 -0
  7. package/data/ck3/data_types/data_types_internalclausewitzgui.txt +14843 -0
  8. package/data/ck3/data_types/data_types_script.txt +4251 -0
  9. package/data/ck3/data_types/data_types_uncategorized.txt +109984 -0
  10. package/data/ck3/freqs.json +1 -0
  11. package/data/ck3/guiSchema.json +6344 -0
  12. package/data/ck3/script_docs/effects.log +16059 -0
  13. package/data/ck3/script_docs/event_targets.log +2098 -0
  14. package/data/ck3/script_docs/modifiers.log +2228 -0
  15. package/data/ck3/script_docs/on_actions.log +5275 -0
  16. package/data/ck3/script_docs/triggers.log +11991 -0
  17. package/data/ck3/structures.json +9743 -0
  18. package/data/ck3/wikidocs/ATTRIBUTION.md +18 -0
  19. package/data/ck3/wikidocs/Data_types.md +2568 -0
  20. package/data/ck3/wikidocs/Effects_list.md +1176 -0
  21. package/data/ck3/wikidocs/Scopes_list.md +341 -0
  22. package/data/ck3/wikidocs/Triggers_list.md +1097 -0
  23. package/data/eu5/data_types/data_types_common.txt +2087 -0
  24. package/data/eu5/data_types/data_types_gui.txt +6732 -0
  25. package/data/eu5/data_types/data_types_internalclausewitzgui.txt +19276 -0
  26. package/data/eu5/data_types/data_types_script.txt +5688 -0
  27. package/data/eu5/data_types/data_types_uncategorized.txt +135569 -0
  28. package/data/vic3/data_types/data_types_common.txt +2021 -0
  29. package/data/vic3/data_types/data_types_gui.txt +5592 -0
  30. package/data/vic3/data_types/data_types_internalclausewitzgui.txt +17304 -0
  31. package/data/vic3/data_types/data_types_script.txt +2817 -0
  32. package/data/vic3/data_types/data_types_uncategorized.txt +84354 -0
  33. package/data/vic3/freqs.json +1 -0
  34. package/data/vic3/guiSchema.json +5578 -0
  35. package/data/vic3/script_docs/effects.log +38135 -0
  36. package/data/vic3/script_docs/event_targets.log +2028 -0
  37. package/data/vic3/script_docs/modifiers.log +18954 -0
  38. package/data/vic3/script_docs/on_actions.log +1561 -0
  39. package/data/vic3/script_docs/triggers.log +15738 -0
  40. package/data/vic3/structures.json +10189 -0
  41. package/dist/server.js +63668 -0
  42. package/media/px-lsp.svg +12 -0
  43. package/package.json +50 -0
  44. package/src/clientMode.ts +60 -0
  45. package/src/coa/coa.ts +184 -0
  46. package/src/coa/coaParse.ts +267 -0
  47. package/src/context.ts +78 -0
  48. package/src/contextKeywords.ts +224 -0
  49. package/src/data/dataBindingMacros.ts +82 -0
  50. package/src/data/dataFnDocs.ts +152 -0
  51. package/src/data/dataFnUsage.ts +431 -0
  52. package/src/data/dataTypes.ts +279 -0
  53. package/src/data/defines.ts +123 -0
  54. package/src/data/docsParser.ts +453 -0
  55. package/src/data/keywordDocs.ts +98 -0
  56. package/src/data/modifierTemplates.ts +143 -0
  57. package/src/data/textFormatting.ts +165 -0
  58. package/src/data/wikiDocs.ts +187 -0
  59. package/src/dds/decoder.ts +1007 -0
  60. package/src/dds/encode.ts +235 -0
  61. package/src/dds/index.ts +58 -0
  62. package/src/dds/png.ts +96 -0
  63. package/src/dds/tga.ts +62 -0
  64. package/src/documents.ts +35 -0
  65. package/src/features/assetPaths.ts +169 -0
  66. package/src/features/codeActions.ts +148 -0
  67. package/src/features/colors.ts +244 -0
  68. package/src/features/completion.ts +961 -0
  69. package/src/features/datafunction.ts +729 -0
  70. package/src/features/definition.ts +84 -0
  71. package/src/features/diagnostics.ts +244 -0
  72. package/src/features/folding.ts +106 -0
  73. package/src/features/formatting.ts +60 -0
  74. package/src/features/guiLanguage.ts +366 -0
  75. package/src/features/guiNavigation.ts +140 -0
  76. package/src/features/guiTree.ts +97 -0
  77. package/src/features/hover.ts +817 -0
  78. package/src/features/hoverRender.ts +222 -0
  79. package/src/features/inlayHints.ts +147 -0
  80. package/src/features/locFormatting.ts +127 -0
  81. package/src/features/references.ts +70 -0
  82. package/src/features/rename.ts +135 -0
  83. package/src/features/scopeAt.ts +65 -0
  84. package/src/features/semanticTokens.ts +188 -0
  85. package/src/features/signatureHelp.ts +72 -0
  86. package/src/features/symbols.ts +241 -0
  87. package/src/features/textureHover.ts +143 -0
  88. package/src/features/workspaceSymbols.ts +69 -0
  89. package/src/games/active.ts +19 -0
  90. package/src/games/ck3/ambientScopes.ts +273 -0
  91. package/src/games/ck3/index.ts +38 -0
  92. package/src/games/ck3/meta.ts +28 -0
  93. package/src/games/ck3/modifierPlaceholders.ts +61 -0
  94. package/src/games/ck3/saveSchema.ts +134 -0
  95. package/src/games/ck3/scaffolds.ts +197 -0
  96. package/src/games/ck3/schema.ts +422 -0
  97. package/src/games/ck3/structures.ts +887 -0
  98. package/src/games/eu5/index.ts +75 -0
  99. package/src/games/eu5/meta.ts +44 -0
  100. package/src/games/eu5/scaffolds.ts +49 -0
  101. package/src/games/eu5/schema.generated.ts +1043 -0
  102. package/src/games/jomini/variables.ts +134 -0
  103. package/src/games/profile.ts +205 -0
  104. package/src/games/registry.ts +27 -0
  105. package/src/games/vic3/index.ts +52 -0
  106. package/src/games/vic3/meta.ts +55 -0
  107. package/src/games/vic3/saveSchema.ts +77 -0
  108. package/src/games/vic3/scaffolds.ts +135 -0
  109. package/src/games/vic3/schema.ts +650 -0
  110. package/src/games/vic3/structures.ts +33 -0
  111. package/src/gui/anchorSpec.ts +66 -0
  112. package/src/gui/declMarkers.ts +30 -0
  113. package/src/gui/fillGeometry.ts +101 -0
  114. package/src/gui/guiDefs.ts +386 -0
  115. package/src/gui/guiDependencies.ts +352 -0
  116. package/src/gui/guiLinks.ts +64 -0
  117. package/src/gui/layoutEngine.ts +1998 -0
  118. package/src/gui/layoutService.ts +221 -0
  119. package/src/gui/measuredMetrics.ts +21 -0
  120. package/src/gui/previewService.ts +89 -0
  121. package/src/gui/saveSchema.ts +220 -0
  122. package/src/gui/saveValues.ts +399 -0
  123. package/src/gui/saveZip.ts +60 -0
  124. package/src/gui/sourceEdit.ts +535 -0
  125. package/src/gui/sourceEditService.ts +439 -0
  126. package/src/gui/sourceModel.ts +603 -0
  127. package/src/gui/textResolve.ts +145 -0
  128. package/src/gui/textureInfo.ts +106 -0
  129. package/src/gui/vocabulary.ts +149 -0
  130. package/src/gui/widgetEdit.ts +52 -0
  131. package/src/gui/widgetInfo.ts +245 -0
  132. package/src/index/docComments.ts +103 -0
  133. package/src/index/extract.ts +252 -0
  134. package/src/index/indexer.ts +369 -0
  135. package/src/index/intern.ts +101 -0
  136. package/src/index/lazyRefs.ts +145 -0
  137. package/src/index/modOrigin.ts +69 -0
  138. package/src/index/references.ts +534 -0
  139. package/src/overview/dependencies.ts +240 -0
  140. package/src/overview/eventBanner.ts +95 -0
  141. package/src/overview/eventDetail.ts +482 -0
  142. package/src/overview/eventGraph.ts +617 -0
  143. package/src/overview/eventVocabulary.ts +214 -0
  144. package/src/overview/locCoverage.ts +138 -0
  145. package/src/overview/modOverview.ts +29 -0
  146. package/src/overview/overrides.ts +89 -0
  147. package/src/parseCache.ts +81 -0
  148. package/src/parser/cst.ts +257 -0
  149. package/src/parser/encoding.ts +106 -0
  150. package/src/parser/index.ts +7 -0
  151. package/src/parser/lexer.ts +245 -0
  152. package/src/parser/locParser.ts +276 -0
  153. package/src/parser/parser.ts +360 -0
  154. package/src/schema/freqs.ts +70 -0
  155. package/src/schema/loader.ts +113 -0
  156. package/src/schema/types.ts +142 -0
  157. package/src/scopes/inference.ts +478 -0
  158. package/src/scopes/model.ts +148 -0
  159. package/src/scopes/varTypes.ts +290 -0
  160. package/src/server.ts +1894 -0
  161. package/src/serverData.ts +98 -0
  162. package/src/structure.ts +56 -0
  163. package/src/wordAt.ts +49 -0
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Preview values read from a real save game, so the GUI designer can draw
3
+ * `[GetPlayer.GetName]` as "Great Britain" instead of a placeholder chip.
4
+ *
5
+ * A save is Jomini script text, but a big one can run ~115 MB and 6.5 million
6
+ * lines, so nothing here parses the file: it STREAMS the file line by line,
7
+ * cuts out the handful of blocks a preview needs (the meta block, the played
8
+ * country, its ruler and heir, its capital) and parses only those with the
9
+ * ordinary tolerant parser. Reading stops as soon as the last needed block has
10
+ * gone by.
11
+ *
12
+ * Which blocks those are, and where they sit, is the active profile's
13
+ * `saveSchema` (gui/saveSchema.ts, built per game under games/<id>/): this
14
+ * module knows two save shapes and no game. A save may keep its entities in
15
+ * id-keyed registries answered in one pass, or name the played character at the
16
+ * very END of the file, which costs one cheap chunk scan for the id before the
17
+ * streaming pass. A packed save keeps its script in a zip entry (`saveZip.ts`
18
+ * finds it) and is inflated on the way through.
19
+ *
20
+ * What comes back is the `previewValues` map a layout request already takes:
21
+ * datafunction chains WITHOUT brackets -> display text. A field the save does
22
+ * not carry is simply absent — a preview shows what is knowable and never
23
+ * invents a value.
24
+ *
25
+ * Ironman and binary saves are not supported and say so; melting them is a
26
+ * different tool.
27
+ *
28
+ * fs/zlib only (the save path); the loc lookup is injected. No vscode.
29
+ */
30
+ import * as fs from "fs";
31
+ import * as readline from "readline";
32
+ import * as zlib from "zlib";
33
+ import type { Readable } from "stream";
34
+ import type { GuiSaveValuesResult } from "@px-lsp/protocol/protocol";
35
+ import { parseScript, type BlockNode } from "../parser";
36
+ import { findZipEntry, ZIP_HEAD_BYTES, type ZipEntry } from "./saveZip";
37
+ import {
38
+ DEFAULT_SAVE_SCHEMA,
39
+ formatDate,
40
+ scalar,
41
+ type SaveContext,
42
+ type SaveEntities,
43
+ type SaveSchema,
44
+ type SaveSlot,
45
+ } from "./saveSchema";
46
+
47
+ export interface SaveValuesOptions {
48
+ /** The active profile's id; goes back on the wire as `source.game`. */
49
+ gameId: string;
50
+ /** How the profile's saves are read. Absent = the meta-only default. */
51
+ schema?: SaveSchema;
52
+ /** The configured language's value for a loc key, or undefined. */
53
+ loc: (key: string) => string | undefined;
54
+ }
55
+
56
+ export const IRONMAN_ERROR =
57
+ "ironman or binary save: save a normal (non-ironman) game to use it for previews";
58
+
59
+ /** The block every Jomini save opens with, and the key that refuses it. */
60
+ const META_BLOCK = "meta_data";
61
+ const IRONMAN_KEY = "ironman";
62
+ const DEFAULT_DATE_KEY = "game_date";
63
+ const DEFAULT_NAME_KEY = "name";
64
+
65
+ /** The entry a packed save keeps its script in. */
66
+ const PACKED_ENTRY = "gamestate";
67
+
68
+ /**
69
+ * Parse one cut-out `{ ... }` chunk. The chunk may be truncated (a capture cap
70
+ * hit), which the tolerant parser reports as an unclosed brace while still
71
+ * giving back the statements it did read — exactly what is wanted here.
72
+ */
73
+ function parseBlock(text: string): BlockNode | undefined {
74
+ if (!text) return undefined;
75
+ const { root } = parseScript(`x=${text}`);
76
+ const first = root.statements[0];
77
+ if (first?.kind === "assignment" && first.value?.kind === "block") return first.value;
78
+ return undefined;
79
+ }
80
+
81
+ /** Read a save's preview values. Never throws: a failure comes back as `error`. */
82
+ export async function readSaveValues(file: string, options: SaveValuesOptions): Promise<GuiSaveValuesResult> {
83
+ const blank = { values: {}, source: { name: "", date: "", game: options.gameId } };
84
+ const schema = options.schema ?? DEFAULT_SAVE_SCHEMA;
85
+ let entities: SaveEntities;
86
+ try {
87
+ entities = await streamSave(file, schema, options.loc);
88
+ } catch (e) {
89
+ return { ...blank, error: `cannot read save: ${(e as Error).message}` };
90
+ }
91
+ if (!entities.meta) return { ...blank, error: IRONMAN_ERROR };
92
+
93
+ const date = formatDate(scalar(entities.meta, schema.dateKey ?? DEFAULT_DATE_KEY));
94
+ const name = scalar(entities.meta, schema.nameKey ?? DEFAULT_NAME_KEY) ?? "";
95
+ const source = { name, date, game: options.gameId };
96
+ if (scalar(entities.meta, IRONMAN_KEY) === "yes") {
97
+ return { values: {}, source, error: IRONMAN_ERROR };
98
+ }
99
+
100
+ const ctx: SaveContext = {
101
+ ...entities,
102
+ tag: schema.registry ? scalar(entities.country, schema.registry.tagKey) : undefined,
103
+ date,
104
+ loc: options.loc,
105
+ };
106
+ const values: Record<string, string> = {};
107
+ for (const mapping of schema.mappings) {
108
+ const value = mapping.read(ctx);
109
+ if (!value) continue;
110
+ for (const chain of mapping.chains) values[chain] = value;
111
+ }
112
+ if (schema.expand) Object.assign(values, schema.expand(ctx));
113
+ return { values, source };
114
+ }
115
+
116
+ /**
117
+ * A text save's header line is short and printable. An ironman or compressed
118
+ * body is neither, and there is nothing here to read out of it.
119
+ */
120
+ function looksBinary(line: string): boolean {
121
+ if (line.length > 200) return true;
122
+ for (let i = 0; i < line.length; i++) {
123
+ const c = line.charCodeAt(i);
124
+ if (c < 9 || (c > 13 && c < 32)) return true;
125
+ }
126
+ return false;
127
+ }
128
+
129
+ /** The first `ZIP_HEAD_BYTES` of the file, or fewer if it is shorter. */
130
+ async function readHead(file: string): Promise<Buffer> {
131
+ const handle = await fs.promises.open(file, "r");
132
+ try {
133
+ const buf = Buffer.alloc(ZIP_HEAD_BYTES);
134
+ const { bytesRead } = await handle.read(buf, 0, ZIP_HEAD_BYTES, 0);
135
+ return buf.subarray(0, bytesRead);
136
+ } finally {
137
+ await handle.close();
138
+ }
139
+ }
140
+
141
+ /**
142
+ * The save's script as a byte stream: the file itself, or the packed entry
143
+ * inflated out of it. `close()` tears the whole chain down, which `destroy()`
144
+ * on the tail alone would not do.
145
+ */
146
+ interface Body {
147
+ input: Readable;
148
+ close: () => void;
149
+ }
150
+
151
+ function openBody(file: string, entry: ZipEntry | undefined): Body {
152
+ if (!entry) {
153
+ const plain = fs.createReadStream(file);
154
+ return { input: plain, close: () => plain.destroy() };
155
+ }
156
+ const end = entry.compressedSize === undefined ? undefined : entry.dataStart + entry.compressedSize - 1;
157
+ const raw = fs.createReadStream(file, { start: entry.dataStart, end });
158
+ if (entry.method === 0) return { input: raw, close: () => raw.destroy() };
159
+ // Raw deflate ends itself at the end of the stream, so an unknown compressed
160
+ // size (a data descriptor) costs nothing.
161
+ const inflate = zlib.createInflateRaw();
162
+ raw.on("error", (e) => inflate.destroy(e));
163
+ raw.pipe(inflate);
164
+ return {
165
+ input: inflate,
166
+ close: () => {
167
+ raw.destroy();
168
+ inflate.destroy();
169
+ },
170
+ };
171
+ }
172
+
173
+ /** The zip entry a packed save keeps its script in. */
174
+ async function packedGamestate(file: string): Promise<ZipEntry | undefined> {
175
+ const entry = findZipEntry(await readHead(file));
176
+ if (!entry || entry.name !== PACKED_ENTRY) return undefined;
177
+ return entry.method === 0 || entry.method === 8 ? entry : undefined;
178
+ }
179
+
180
+ /**
181
+ * The played record's id, for a save that names it near the END of the script.
182
+ * A chunk scan for the marker costs a quarter of what a line-by-line pass would
183
+ * (225 ms against 1.8 s on a 4.9-million-line save) and holds only a 2 KB
184
+ * window.
185
+ */
186
+ async function findPlayerId(
187
+ file: string,
188
+ entry: ZipEntry | undefined,
189
+ marker: string,
190
+ idKey: string
191
+ ): Promise<string | undefined> {
192
+ const { input, close } = openBody(file, entry);
193
+ let window = "";
194
+ let at = -1;
195
+ try {
196
+ for await (const chunk of input) {
197
+ window += (chunk as Buffer).toString("latin1");
198
+ if (at < 0) {
199
+ at = window.indexOf(marker);
200
+ if (at < 0) window = window.slice(-marker.length);
201
+ }
202
+ if (at >= 0 && window.length - at >= 2048) break;
203
+ }
204
+ } finally {
205
+ close();
206
+ }
207
+ if (at < 0) return undefined;
208
+ return new RegExp(`\\b${idKey}=(\\d+)`).exec(window.slice(at, at + 2048))?.[1];
209
+ }
210
+
211
+ /**
212
+ * Read the blocks a preview needs. A registry save gets them all in one pass; a
213
+ * record save needs the meta first (which also settles ironman/binary, so a
214
+ * save with nothing to read costs one line), then the player's id, then the
215
+ * pass that cuts out the record.
216
+ */
217
+ async function streamSave(
218
+ file: string,
219
+ schema: SaveSchema,
220
+ loc: (key: string) => string | undefined
221
+ ): Promise<SaveEntities> {
222
+ const entry = await packedGamestate(file);
223
+ const out = await scanBody(file, entry, schema, loc, undefined);
224
+ const record = schema.record;
225
+ if (!record || !out.meta || scalar(out.meta, IRONMAN_KEY) === "yes") return out;
226
+
227
+ const playerId = await findPlayerId(file, entry, record.marker, record.idKey);
228
+ if (!playerId) return out;
229
+ out.character = (await scanBody(file, entry, schema, loc, playerId)).character;
230
+ return out;
231
+ }
232
+
233
+ /**
234
+ * Lines kept per cut-out block. A country entry runs a few hundred lines and
235
+ * everything read from one sits near its top; the cap only bounds what a
236
+ * pathological save could cost. (A real character record measures 360 lines.)
237
+ */
238
+ const MAX_CAPTURE_LINES = 4000;
239
+
240
+ /** A block being cut out of the stream. */
241
+ interface Capture {
242
+ /** Where it sits, outermost key first: `["country_manager","database","1"]`. */
243
+ path: string[];
244
+ lines: string[];
245
+ }
246
+
247
+ /**
248
+ * One pass over the script. A registry save writes its blocks in the order the
249
+ * reader needs them (the meta, then the countries, then what a country points
250
+ * at), which is why the played country's capital and ruler ids are already
251
+ * known when their own blocks go by.
252
+ */
253
+ async function scanBody(
254
+ file: string,
255
+ entry: ZipEntry | undefined,
256
+ schema: SaveSchema,
257
+ loc: (key: string) => string | undefined,
258
+ playerId: string | undefined
259
+ ): Promise<SaveEntities> {
260
+ const { input, close } = openBody(file, entry);
261
+ const rl = readline.createInterface({ input, crlfDelay: Infinity });
262
+ const out: SaveEntities = {};
263
+ const registry = schema.registry;
264
+ const record = playerId === undefined ? undefined : schema.record;
265
+ const tagLine = registry && new RegExp(`^\\s*${registry.tagKey}="?([A-Za-z_0-9]+)"?`);
266
+ const mainLine = registry && new RegExp(`^\\s*${registry.mainKey}=yes\\b`);
267
+
268
+ // The block keys we are inside of, outermost first.
269
+ const stack: string[] = [];
270
+ let capture: Capture | null = null;
271
+ /** The country entry going by right now; kept only if it is the player's. */
272
+ let candidate: { tag?: string; main: boolean } | null = null;
273
+ let playerCountry: string | null = null;
274
+ let fallbackCountry: string | null = null;
275
+ const linkIds: Partial<Record<SaveSlot, string>> = {};
276
+ let lineNo = 0;
277
+ let done = false;
278
+
279
+ for await (const raw of rl) {
280
+ lineNo++;
281
+ const line = lineNo === 1 && raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
282
+
283
+ // A binary or compressed body is not script: a text save's header line is
284
+ // short and printable, and the meta block opens right after it.
285
+ if (lineNo === 1 && looksBinary(line)) break;
286
+ if (lineNo > 6 && !out.meta && !capture) break;
287
+
288
+ let captureStart = capture ? 0 : -1;
289
+
290
+ // Only a line carrying one of these can change the shape; the rest is
291
+ // `key=value` noise, and skipping it is what keeps 6 million lines cheap.
292
+ if (line.includes("{") || line.includes("}") || line.includes('"')) {
293
+ let inString = false;
294
+ for (let i = 0; i < line.length; i++) {
295
+ const c = line[i];
296
+ if (inString) {
297
+ if (c === '"') inString = false;
298
+ continue;
299
+ }
300
+ if (c === '"') inString = true;
301
+ else if (c === "{") {
302
+ stack.push(keyBefore(line, i));
303
+ if (!capture && wanted()) {
304
+ capture = { path: [...stack], lines: [] };
305
+ captureStart = i;
306
+ if (registry && stack[0] === registry.countryBlock) candidate = { main: false };
307
+ }
308
+ } else if (c === "}") {
309
+ const closed = stack.pop();
310
+ if (capture && stack.length === capture.path.length - 1) {
311
+ const tail = line.slice(Math.max(captureStart, 0), i + 1);
312
+ const { path, lines } = capture;
313
+ capture = null;
314
+ captureStart = -1;
315
+ keep(path, [...lines, tail].join("\n"));
316
+ } else if (!capture && stack.length === 0 && registry && closed === registry.countryBlock) {
317
+ finishCountries();
318
+ }
319
+ }
320
+ }
321
+ }
322
+
323
+ if (capture && captureStart >= 0) {
324
+ if (capture.lines.length < MAX_CAPTURE_LINES) capture.lines.push(line.slice(captureStart));
325
+ if (candidate) scanCountry(line);
326
+ }
327
+ if (done) break;
328
+ }
329
+ rl.close();
330
+ close();
331
+ finishCountries();
332
+ return out;
333
+
334
+ /** Is the block we just entered one of the few worth cutting out? */
335
+ function wanted(): boolean {
336
+ if (stack.length === 1) return stack[0] === META_BLOCK;
337
+ if (record) return stack.length === 2 && stack[0] === record.block && stack[1] === playerId;
338
+ if (!registry || stack.length !== 3 || stack[1] !== registry.entries) return false;
339
+ if (stack[0] === registry.countryBlock) return !playerCountry;
340
+ return registry.links.some((l) => l.block === stack[0] && linkIds[l.slot] === stack[2]);
341
+ }
342
+
343
+ /** A country entry says who it is in two lines near its top. */
344
+ function scanCountry(line: string): void {
345
+ if (!candidate || !tagLine || !mainLine) return;
346
+ const def = tagLine.exec(line);
347
+ if (def) candidate.tag = def[1];
348
+ else if (mainLine.test(line)) candidate.main = true;
349
+ }
350
+
351
+ /** A cut-out block just closed: file it, and note what it points at. */
352
+ function keep(path: string[], text: string): void {
353
+ if (path[0] === META_BLOCK) {
354
+ out.meta = parseBlock(text);
355
+ // Only a registry save reads on from the meta in this same pass; a record
356
+ // save that has no player id yet, or a game with no schema, is finished.
357
+ if (!registry && !record) done = true;
358
+ return;
359
+ }
360
+ if (record && path[0] === record.block) {
361
+ out.character = parseBlock(text);
362
+ done = true;
363
+ return;
364
+ }
365
+ if (!registry) return;
366
+ if (path[0] === registry.countryBlock) {
367
+ // The played country is the one whose tag localizes to the campaign name
368
+ // the meta states (`player_manager` is empty in a single-player save).
369
+ const name = scalar(out.meta, schema.nameKey ?? DEFAULT_NAME_KEY);
370
+ const tag = candidate?.tag;
371
+ if (tag && (loc(tag) === name || tag === name)) playerCountry = text;
372
+ else if (candidate?.main && !fallbackCountry) fallbackCountry = text;
373
+ candidate = null;
374
+ return;
375
+ }
376
+ for (const link of registry.links) {
377
+ if (link.block === path[0] && linkIds[link.slot] === path[2]) out[link.slot] = parseBlock(text);
378
+ }
379
+ // The linked entries are the last of the blocks we need that a save writes.
380
+ if (registry.links.every((l) => !linkIds[l.slot] || out[l.slot])) done = true;
381
+ }
382
+
383
+ /**
384
+ * The country registry has gone by: settle on the played country (the tag
385
+ * match, else the first main tag) and read the ids the later blocks are
386
+ * found by.
387
+ */
388
+ function finishCountries(): void {
389
+ if (!registry || out.country || !(playerCountry ?? fallbackCountry)) return;
390
+ out.country = parseBlock(playerCountry ?? fallbackCountry ?? "");
391
+ for (const link of registry.links) linkIds[link.slot] = scalar(out.country, link.key);
392
+ }
393
+ }
394
+
395
+ /** The key in `key = {`, from the text just before the brace. */
396
+ function keyBefore(line: string, brace: number): string {
397
+ const m = /([A-Za-z_0-9.-]+)\s*=\s*$/.exec(line.slice(Math.max(0, brace - 64), brace));
398
+ return m ? m[1] : "";
399
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Where a zip-packed save keeps its script.
3
+ *
4
+ * A packed save is a short text header plus a plain-text `meta_data` block,
5
+ * and then a zip archive holding ONE entry, `gamestate`, deflated. Reading it needs nothing more than that entry's byte offset and
6
+ * compression method: `zlib.createInflateRaw()` over the rest of the file
7
+ * stops itself at the end of the deflate stream, so the compressed size only
8
+ * matters for a stored (uncompressed) entry.
9
+ *
10
+ * This is the whole zip support the toolkit needs — one local file header,
11
+ * no central directory, no npm dependency. Pure: bytes in, offsets out.
12
+ */
13
+
14
+ /** How much of a save's head is searched for the archive; a real one packs it within ~32 KB. */
15
+ export const ZIP_HEAD_BYTES = 1 << 20;
16
+
17
+ /** Local file header signature, `PK\x03\x04`. */
18
+ const SIGNATURE = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
19
+
20
+ /** Field offsets inside a local file header (APPNOTE 4.3.7). */
21
+ const LOCAL_HEADER_BYTES = 30;
22
+
23
+ export interface ZipEntry {
24
+ name: string;
25
+ /** 0 = stored, 8 = deflate. Nothing else is supported. */
26
+ method: number;
27
+ /** Byte offset of the entry's data in the file. */
28
+ dataStart: number;
29
+ /**
30
+ * Compressed byte length, or undefined when the header does not carry it
31
+ * (general-purpose bit 3: the sizes trail the data in a data descriptor).
32
+ */
33
+ compressedSize?: number;
34
+ }
35
+
36
+ /**
37
+ * The first zip entry in `head`, or undefined when the bytes are not a zip.
38
+ * `head` is the start of the file; an entry whose header does not fit inside
39
+ * it counts as absent rather than as a truncated answer.
40
+ */
41
+ export function findZipEntry(head: Buffer): ZipEntry | undefined {
42
+ const at = head.indexOf(SIGNATURE);
43
+ if (at < 0 || at + LOCAL_HEADER_BYTES > head.length) return undefined;
44
+
45
+ const flags = head.readUInt16LE(at + 6);
46
+ const method = head.readUInt16LE(at + 8);
47
+ const compressed = head.readUInt32LE(at + 18);
48
+ const nameLength = head.readUInt16LE(at + 26);
49
+ const extraLength = head.readUInt16LE(at + 28);
50
+ const nameAt = at + LOCAL_HEADER_BYTES;
51
+ if (nameAt + nameLength > head.length) return undefined;
52
+
53
+ const streamed = (flags & 0x08) !== 0;
54
+ return {
55
+ name: head.toString("latin1", nameAt, nameAt + nameLength),
56
+ method,
57
+ dataStart: nameAt + nameLength + extraLength,
58
+ compressedSize: streamed || compressed === 0 ? undefined : compressed,
59
+ };
60
+ }