Skip to content

Commit 987bbb3

Browse files
committed
refactor(binary): share file-derived ParseOptions across CLI and editor
Split ParseOptions into two named axes: - file-derived (a function of where the file sits on disk — today the sibling proto/ pidResolver auto-load) - frontend-preference (skipMapTiles for editor render perf, gracefulMapBoundaries for CLI flag exposure / editor fallback) Move the file-derived axis behind a single library function, buildFileDerivedParseOptions(filePath), in @bgforge/binary. CLI and the VS Code custom editor both delegate to it, so a behavior added there propagates to every frontend instead of silently affecting only one. Add a parity contract test that catches future divergence at PR time. The editor also gains a graceful-map auto-fallback: best-effort display is its job, so a strict parse error on a .map retries with gracefulMapBoundaries enabled. The CLI keeps strict-fail semantics because batch verification needs the error to surface. The actually-used options propagate onto BinaryDocument so incremental edits and revert reuse the same shape.
1 parent a14db04 commit 987bbb3

9 files changed

Lines changed: 396 additions & 32 deletions

binary/src/cli.ts

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,7 @@ import {
1616
createBinaryJsonSnapshot,
1717
loadBinaryJsonSnapshot,
1818
parseBinaryJsonSnapshot,
19-
resolvePidSubType,
20-
loadProDirResolver,
21-
composePidResolvers,
19+
buildFileDerivedParseOptions,
2220
} from "./index";
2321
import {
2422
type FileResult,
@@ -36,35 +34,25 @@ const CLI_PARSE_OPTIONS: ParseOptions = {
3634
const CLI_QUIET = process.argv.includes("-q") || process.argv.includes("--quiet");
3735

3836
/**
39-
* Builds per-file ParseOptions, layering a sibling proto/ override resolver
40-
* on top of the bundled vanilla Fallout 2 table when the input is a MAP and
41-
* a `<map dir>/../proto/` tree exists. Reports stats to stderr (unless -q).
37+
* Builds per-file ParseOptions for the CLI: file-derived options come from
38+
* the shared `@bgforge/binary` builder (today: sibling proto/ pidResolver),
39+
* the CLI's own preference axes (--graceful-map) layer on top. Reports
40+
* proto-load stats to stderr (unless -q).
4241
*/
4342
function buildParseOptionsForFile(filePath: string): ParseOptions {
44-
if (path.extname(filePath).toLowerCase() !== ".map") {
45-
return CLI_PARSE_OPTIONS;
46-
}
47-
48-
const protoBaseDir = path.resolve(path.dirname(filePath), "..", "proto");
49-
if (!fs.existsSync(protoBaseDir)) {
50-
return CLI_PARSE_OPTIONS;
51-
}
52-
53-
const { resolver, stats } = loadProDirResolver(protoBaseDir);
54-
if (stats.filesScanned === 0) {
55-
return CLI_PARSE_OPTIONS;
56-
}
43+
const fileDerived = buildFileDerivedParseOptions(filePath);
5744

58-
if (!CLI_QUIET) {
45+
if (!CLI_QUIET && fileDerived.diagnostics) {
46+
const { protoDir, stats } = fileDerived.diagnostics;
5947
const errSuffix = stats.errors.length > 0 ? `, ${stats.errors.length} errors` : "";
6048
console.error(
61-
`Loaded ${stats.subtypesResolved} proto overrides from ${protoBaseDir} ` +
49+
`Loaded ${stats.subtypesResolved} proto overrides from ${protoDir} ` +
6250
`in ${stats.durationMs.toFixed(0)}ms${errSuffix}`,
6351
);
6452
for (const err of stats.errors) console.error(` ${err}`);
6553
}
6654

67-
return { ...CLI_PARSE_OPTIONS, pidResolver: composePidResolvers(resolver, resolvePidSubType) };
55+
return { ...CLI_PARSE_OPTIONS, ...(fileDerived.pidResolver ? { pidResolver: fileDerived.pidResolver } : {}) };
6856
}
6957

7058
async function processFile(filePath: string, mode: OutputMode): Promise<FileResult> {

binary/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ export {
6868
type ProResolverResult,
6969
type ProResolverStats,
7070
} from "./pro-resolver-loader";
71+
export {
72+
buildFileDerivedParseOptions,
73+
type FileDerivedParseOptions,
74+
type FileDerivedDiagnostics,
75+
} from "./parse-options";
7176

7277
// Side-effect: register the bundled parsers on the registry.
7378
import { proParser } from "./pro";

binary/src/parse-options.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Shared file-derived ParseOptions builder.
3+
*
4+
* `ParseOptions` has two distinct axes:
5+
* - **File-derived** — values that are a function of where the file sits
6+
* on disk (which sibling resources exist, which mod tree it belongs to).
7+
* Must agree across frontends; divergence here is always a bug.
8+
* - **Frontend-preference** — values that legitimately differ per caller
9+
* (`skipMapTiles` for editor render perf, `gracefulMapBoundaries` for
10+
* CLI flag exposure). These stay per-frontend.
11+
*
12+
* This module owns the file-derived axis. Both the CLI and the VS Code
13+
* editor call `buildFileDerivedParseOptions(filePath)` and merge their own
14+
* preferences on top. Adding a new file-derived behavior — e.g. scanning
15+
* a sibling manifest for proto-search-path overrides — is a single edit
16+
* here that propagates to every caller.
17+
*
18+
* Today the only file-derived option is `pidResolver`, auto-loaded from
19+
* `<mapDir>/../proto/` (the standard Fallout 2 mod tree layout). When the
20+
* sibling tree exists and contains at least one matching .pro file, the
21+
* builder layers a filesystem resolver over the bundled vanilla defaults so
22+
* MAP decoding covers modded pids. Otherwise it returns empty options and
23+
* `parser.parse` falls back to its own bundled-table default.
24+
*/
25+
26+
import * as fs from "fs";
27+
import * as path from "path";
28+
import { resolvePidSubType } from "./pid-resolver";
29+
import { loadProDirResolver, composePidResolvers, type ProResolverStats } from "./pro-resolver-loader";
30+
import type { ParseOptions } from "./types";
31+
32+
export interface FileDerivedDiagnostics {
33+
/** Absolute path of the proto/ tree that was scanned. */
34+
readonly protoDir: string;
35+
/** Stats reported by `loadProDirResolver` — files scanned, errors, duration. */
36+
readonly stats: ProResolverStats;
37+
}
38+
39+
export interface FileDerivedParseOptions extends Pick<ParseOptions, "pidResolver"> {
40+
/**
41+
* Optional diagnostics from filesystem-touching scans (e.g. proto/ load
42+
* stats). Frontends use this for stderr or status-line logging; the
43+
* parser itself never reads it.
44+
*/
45+
readonly diagnostics?: FileDerivedDiagnostics;
46+
}
47+
48+
/**
49+
* Build the file-derived axis of ParseOptions for `filePath`. Returns
50+
* empty options when there is nothing on disk to enrich the parse with.
51+
*/
52+
export function buildFileDerivedParseOptions(filePath: string): FileDerivedParseOptions {
53+
if (path.extname(filePath).toLowerCase() !== ".map") {
54+
return {};
55+
}
56+
57+
const protoBaseDir = path.resolve(path.dirname(filePath), "..", "proto");
58+
if (!fs.existsSync(protoBaseDir)) {
59+
return {};
60+
}
61+
62+
const { resolver, stats } = loadProDirResolver(protoBaseDir);
63+
if (stats.filesScanned === 0) {
64+
return {};
65+
}
66+
67+
return {
68+
pidResolver: composePidResolvers(resolver, resolvePidSubType),
69+
diagnostics: { protoDir: protoBaseDir, stats },
70+
};
71+
}

binary/test/parse-options.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Shared file-derived ParseOptions builder.
3+
*
4+
* `buildFileDerivedParseOptions(filePath)` is the single source of truth for
5+
* the *file-derived* axis of `ParseOptions`: any setting whose value is a
6+
* function of where the file sits on disk (which sibling resources exist,
7+
* which mod tree it belongs to, etc.). Both the CLI and the editor call this
8+
* so a behavior added here propagates to every frontend.
9+
*
10+
* Frontend-preference axes (`skipMapTiles`, `gracefulMapBoundaries`) stay
11+
* out of this builder by design — those legitimately differ per caller.
12+
*/
13+
14+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
15+
import * as fs from "fs";
16+
import * as path from "path";
17+
import * as os from "os";
18+
import { buildFileDerivedParseOptions } from "../src/parse-options";
19+
20+
const FIXTURES = path.resolve("client/testFixture/proto");
21+
22+
let tmpDir: string;
23+
24+
beforeEach(() => {
25+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fgbin-fdo-"));
26+
});
27+
28+
afterEach(() => {
29+
fs.rmSync(tmpDir, { recursive: true, force: true });
30+
});
31+
32+
function makeMapDirWithProto(): string {
33+
const mapsDir = path.join(tmpDir, "maps");
34+
const protoDir = path.join(tmpDir, "proto");
35+
fs.mkdirSync(mapsDir);
36+
fs.mkdirSync(path.join(protoDir, "items"), { recursive: true });
37+
fs.mkdirSync(path.join(protoDir, "scenery"), { recursive: true });
38+
fs.copyFileSync(path.join(FIXTURES, "items", "00000031.pro"), path.join(protoDir, "items", "00000031.pro"));
39+
fs.copyFileSync(path.join(FIXTURES, "scenery", "00000008.pro"), path.join(protoDir, "scenery", "00000008.pro"));
40+
return path.join(mapsDir, "fake.map");
41+
}
42+
43+
describe("buildFileDerivedParseOptions", () => {
44+
it("returns empty options for a non-.map file path", () => {
45+
const opts = buildFileDerivedParseOptions("/some/where/file.pro");
46+
expect(opts.pidResolver).toBeUndefined();
47+
expect(opts.diagnostics).toBeUndefined();
48+
});
49+
50+
it("returns empty options for a .map file with no sibling proto/ dir", () => {
51+
const mapsDir = path.join(tmpDir, "maps");
52+
fs.mkdirSync(mapsDir);
53+
const opts = buildFileDerivedParseOptions(path.join(mapsDir, "any.map"));
54+
expect(opts.pidResolver).toBeUndefined();
55+
expect(opts.diagnostics).toBeUndefined();
56+
});
57+
58+
it("returns a pidResolver covering items from a sibling proto/items/", () => {
59+
const mapPath = makeMapDirWithProto();
60+
const opts = buildFileDerivedParseOptions(mapPath);
61+
expect(opts.pidResolver).toBeDefined();
62+
// 00000031.pro is a vanilla ammo proto (subType 4 / Ammo).
63+
expect(opts.pidResolver!(31)).toBe(4);
64+
});
65+
66+
it("returns a pidResolver covering scenery from a sibling proto/scenery/", () => {
67+
const mapPath = makeMapDirWithProto();
68+
const opts = buildFileDerivedParseOptions(mapPath);
69+
// 00000008.pro is a vanilla door proto (subType 0 / Door); pid = (2<<24)|8.
70+
expect(opts.pidResolver!(0x02000008)).toBe(0);
71+
});
72+
73+
it("layers proto/ overrides on top of the bundled vanilla defaults", () => {
74+
const mapPath = makeMapDirWithProto();
75+
const opts = buildFileDerivedParseOptions(mapPath);
76+
// pid 161 is a vanilla weapon in the bundled table → subType 3 (Weapon).
77+
// It is NOT in the temp proto dir, so this exercises fallback.
78+
expect(opts.pidResolver!(161)).toBe(3);
79+
});
80+
81+
it("reports diagnostics so callers can log scan stats", () => {
82+
const mapPath = makeMapDirWithProto();
83+
const opts = buildFileDerivedParseOptions(mapPath);
84+
expect(opts.diagnostics).toBeDefined();
85+
expect(opts.diagnostics!.protoDir).toBe(path.join(tmpDir, "proto"));
86+
expect(opts.diagnostics!.stats.filesScanned).toBe(2);
87+
expect(opts.diagnostics!.stats.subtypesResolved).toBe(2);
88+
expect(opts.diagnostics!.stats.errors).toEqual([]);
89+
});
90+
91+
it("returns empty options when sibling proto/ exists but has zero matching files", () => {
92+
const mapsDir = path.join(tmpDir, "maps");
93+
const protoDir = path.join(tmpDir, "proto");
94+
fs.mkdirSync(mapsDir);
95+
fs.mkdirSync(path.join(protoDir, "items"), { recursive: true });
96+
// Empty proto/items/ — filesScanned will be 0, no resolver attached.
97+
const opts = buildFileDerivedParseOptions(path.join(mapsDir, "any.map"));
98+
expect(opts.pidResolver).toBeUndefined();
99+
expect(opts.diagnostics).toBeUndefined();
100+
});
101+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Editor-side parse helper with graceful-map auto-fallback.
3+
*
4+
* The editor is best-effort display: a user opening a `.map` file expects
5+
* a tree they can inspect, not an error stub. The CLI is batch
6+
* verification: the same error must surface so a CI gate can fail. Same
7+
* library, different purposes — so the editor (only) retries permissively
8+
* when a strict parse returns errors that would prevent display.
9+
*
10+
* `parseForEditor` returns the actual options used. The caller stores
11+
* those on the document so subsequent reparses (incremental field edits,
12+
* revert) reuse the same shape — otherwise editing a graceful-loaded map
13+
* would silently re-fail on the next byte rebuild.
14+
*/
15+
16+
import * as path from "path";
17+
import type { BinaryParser, ParseOptions, ParseResult } from "@bgforge/binary";
18+
import { buildEditorParseOptions } from "./binaryEditor-parseOptions";
19+
20+
export interface EditorParseOutcome {
21+
readonly parseResult: ParseResult;
22+
readonly parseOptions: ParseOptions | undefined;
23+
}
24+
25+
export function parseForEditor(parser: BinaryParser, bytes: Uint8Array, filePath: string): EditorParseOutcome {
26+
const baseOptions = buildEditorParseOptions(filePath);
27+
const result = parser.parse(bytes, baseOptions);
28+
if (path.extname(filePath).toLowerCase() === ".map" && result.errors && result.errors.length > 0) {
29+
const gracefulOptions: ParseOptions = { ...baseOptions, gracefulMapBoundaries: true };
30+
const fallback = parser.parse(bytes, gracefulOptions);
31+
if (!fallback.errors || fallback.errors.length === 0) {
32+
return { parseResult: fallback, parseOptions: gracefulOptions };
33+
}
34+
}
35+
return { parseResult: result, parseOptions: baseOptions };
36+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Editor-side ParseOptions builder.
3+
*
4+
* Composes `ParseOptions` for the binary custom editor by combining:
5+
* - File-derived options (`buildFileDerivedParseOptions`) — shared with
6+
* the CLI; today this is the sibling proto/ pidResolver auto-load.
7+
* - Editor-preference options — `skipMapTiles: true` to skip tile field
8+
* materialization, which would otherwise cost ~40k field allocations
9+
* per tree render.
10+
*
11+
* Centralising this here means the parity contract test can call exactly
12+
* the same builder the editor uses, and any future file-derived option
13+
* added to the library propagates to the editor with no code change.
14+
*/
15+
16+
import * as path from "path";
17+
import { buildFileDerivedParseOptions, type ParseOptions } from "@bgforge/binary";
18+
19+
export function buildEditorParseOptions(filePath: string): ParseOptions | undefined {
20+
const ext = path.extname(filePath).toLowerCase();
21+
if (ext !== ".map") return undefined;
22+
const fileDerived = buildFileDerivedParseOptions(filePath);
23+
return {
24+
skipMapTiles: true,
25+
...(fileDerived.pidResolver ? { pidResolver: fileDerived.pidResolver } : {}),
26+
};
27+
}

client/src/editors/binaryEditor.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as path from "path";
88
import { randomBytes } from "crypto";
99
import {
1010
type BinaryParser,
11+
type ParseOptions,
1112
type ParseResult,
1213
parserRegistry,
1314
formatAdapterRegistry,
@@ -29,6 +30,8 @@ import {
2930
resolveStringCharset,
3031
} from "./binaryEditor-lookups";
3132
import { saveBinaryDocumentArtifacts, writeBinaryJsonSnapshot } from "./binaryEditor-save";
33+
import { buildEditorParseOptions } from "./binaryEditor-parseOptions";
34+
import { parseForEditor } from "./binaryEditor-parse";
3235
import { BinaryEditorRefreshGate } from "./binaryEditor-refreshGate";
3336
import { BinaryEditorLocalEditTracker } from "./binaryEditor-localEditTracker";
3437
import { surfaceWebviewRuntimeError } from "../webview-error";
@@ -129,11 +132,11 @@ class BinaryEditorProvider implements vscode.CustomEditorProvider<BinaryDocument
129132
_openContext: vscode.CustomDocumentOpenContext,
130133
_token: vscode.CancellationToken,
131134
): Promise<BinaryDocument> {
132-
const { parseResult, parser } = await this.parseFile(uri);
135+
const { parseResult, parser, parseOptions } = await this.parseFile(uri);
133136
const doc = new BinaryDocument(uri, parseResult, {
134137
parse: parser.parse.bind(parser),
135138
serialize: parser.serialize.bind(parser),
136-
parseOptions: this.getParseOptions(path.extname(uri.fsPath)),
139+
parseOptions,
137140
});
138141

139142
const subscriptions: vscode.Disposable[] = [];
@@ -317,7 +320,7 @@ class BinaryEditorProvider implements vscode.CustomEditorProvider<BinaryDocument
317320
try {
318321
const jsonText = Buffer.from(await vscode.workspace.fs.readFile(jsonUri)).toString("utf8");
319322
const extension = path.extname(document.uri.fsPath);
320-
const parseOptions = this.getParseOptions(extension);
323+
const parseOptions = this.getParseOptions(document.uri.fsPath);
321324
const loaded = loadBinaryJsonSnapshot(jsonText, {
322325
proParseOptions: parseOptions,
323326
mapParseOptions:
@@ -635,11 +638,15 @@ class BinaryEditorProvider implements vscode.CustomEditorProvider<BinaryDocument
635638
return parser as EditableBinaryParser;
636639
}
637640

638-
private getParseOptions(extension: string): { skipMapTiles?: boolean } | undefined {
639-
return extension === ".map" ? { skipMapTiles: true } : undefined;
641+
private getParseOptions(filePath: string): ParseOptions | undefined {
642+
return buildEditorParseOptions(filePath);
640643
}
641644

642-
private async parseFile(uri: vscode.Uri): Promise<{ parseResult: ParseResult; parser: EditableBinaryParser }> {
645+
private async parseFile(uri: vscode.Uri): Promise<{
646+
parseResult: ParseResult;
647+
parser: EditableBinaryParser;
648+
parseOptions: ParseOptions | undefined;
649+
}> {
643650
const extension = path.extname(uri.fsPath);
644651
const parser = this.getEditableParser(extension);
645652

@@ -659,14 +666,13 @@ class BinaryEditorProvider implements vscode.CustomEditorProvider<BinaryDocument
659666
parse: () => parseResult,
660667
serialize: () => new Uint8Array(),
661668
},
669+
parseOptions: undefined,
662670
};
663671
}
664672

665673
const fileData = await vscode.workspace.fs.readFile(uri);
666-
return {
667-
parseResult: parser.parse(fileData, this.getParseOptions(extension)),
668-
parser,
669-
};
674+
const outcome = parseForEditor(parser, fileData, uri.fsPath);
675+
return { parseResult: outcome.parseResult, parser, parseOptions: outcome.parseOptions };
670676
}
671677

672678
// -- HTML rendering (shell only, data sent via postMessage) --------------

0 commit comments

Comments
 (0)