optipack 1.0.0 → 1.0.1
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.
- package/BENCHMARKING.md +127 -0
- package/README.md +84 -179
- package/package.json +36 -34
- package/src/bench.js +231 -0
- package/src/cli.js +114 -49
- package/{test/harness/setup.js → src/harness/driver.js} +28 -10
- package/src/harness/loader-hooks.mjs +63 -0
- package/src/harness/mock/@minecraft/common.mjs +17 -0
- package/src/harness/mock/@minecraft/debug-utilities.mjs +10 -0
- package/src/harness/mock/@minecraft/diagnostics.mjs +14 -0
- package/src/harness/mock/@minecraft/server-admin.mjs +14 -0
- package/src/harness/mock/@minecraft/server-editor.mjs +39 -0
- package/src/harness/mock/@minecraft/server-extras.mjs +538 -0
- package/src/harness/mock/@minecraft/server-gametest.mjs +22 -0
- package/src/harness/mock/@minecraft/server-graphics.mjs +21 -0
- package/src/harness/mock/@minecraft/server-net.mjs +15 -0
- package/src/harness/mock/@minecraft/server-ui.mjs +125 -0
- package/{test → src/harness}/mock/@minecraft/server.mjs +160 -40
- package/src/harness/mock/permissive.mjs +71 -0
- package/src/pragma.js +1 -1
- package/test/differential.test.mjs +2 -2
- package/test/example.test.mjs +3 -3
- package/test/fetch-corpus.mjs +92 -0
- package/test/infra.test.mjs +10 -0
- package/test/server-ui-forms/expected.js +15 -0
- package/test/server-ui-forms/expects.json +1 -0
- package/test/server-ui-forms/input.js +25 -0
- package/test/server-ui-forms/scenario.json +1 -0
- package/test/transforms.test.mjs +4 -4
- package/test/update-mock-stubs.mjs +91 -0
- package/test/loader-hooks.mjs +0 -38
package/src/bench.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `optipack bench` — A/B benchmark of a pack's original vs optimized scripts
|
|
3
|
+
* under the harness mock.
|
|
4
|
+
*
|
|
5
|
+
* Measures, for identical simulated scenarios:
|
|
6
|
+
* - host-boundary crossings by kind (the metric optipack exists to reduce:
|
|
7
|
+
* location reads, getComponent, getDynamicProperty, getEntities, ...).
|
|
8
|
+
* Engine-independent: a crossing is a crossing in V8 or QuickJS.
|
|
9
|
+
* - wall-clock time (INDICATIVE ONLY: Node is V8 with a JIT, Minecraft is
|
|
10
|
+
* QuickJS without one — use the in-game `/script profiler` workflow in
|
|
11
|
+
* BENCHMARKING.md for authoritative numbers).
|
|
12
|
+
* - effect-trace equality (a bench between non-equivalent builds is invalid
|
|
13
|
+
* and is flagged).
|
|
14
|
+
*
|
|
15
|
+
* Determinism: a virtual clock replaces Date during variant execution and
|
|
16
|
+
* advances a fixed 50ms per tick, so time-gated script logic sees identical
|
|
17
|
+
* clocks on both sides regardless of how fast each variant actually runs.
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import { performance } from 'node:perf_hooks';
|
|
23
|
+
|
|
24
|
+
import { makeRecorder, runVariant, assertSameEffects, pathToUrl, ensureHooks } from './harness/driver.js';
|
|
25
|
+
import { runPack } from './pipeline.js';
|
|
26
|
+
import { normalizeConfig } from './config.js';
|
|
27
|
+
|
|
28
|
+
const TICK_MS = 50; // 20 TPS
|
|
29
|
+
|
|
30
|
+
function installVirtualClock() {
|
|
31
|
+
const RealDate = Date;
|
|
32
|
+
let now = RealDate.now();
|
|
33
|
+
class VirtualDate extends RealDate {
|
|
34
|
+
constructor(...args) {
|
|
35
|
+
if (args.length === 0) super(now);
|
|
36
|
+
else super(...args);
|
|
37
|
+
}
|
|
38
|
+
static now() {
|
|
39
|
+
return now;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
globalThis.Date = VirtualDate;
|
|
43
|
+
return {
|
|
44
|
+
advance(ms) { now += ms; },
|
|
45
|
+
restore() { globalThis.Date = RealDate; },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Run one variant end to end and collect measurements.
|
|
51
|
+
*/
|
|
52
|
+
async function measureVariant(entryAbsPath, variant, scenario, ticks) {
|
|
53
|
+
const clock = installVirtualClock();
|
|
54
|
+
const rec = makeRecorder({ reads: scenario.reads, state: scenario.state });
|
|
55
|
+
globalThis.__opkRecorder = rec;
|
|
56
|
+
const t = { importMs: 0, ticksMs: 0 };
|
|
57
|
+
try {
|
|
58
|
+
const importStart = performance.now();
|
|
59
|
+
await import(`${pathToUrl(entryAbsPath)}?opkv=${variant}&t=${Date.now()}-${Math.floor(performance.now() * 1000)}`);
|
|
60
|
+
t.importMs = performance.now() - importStart;
|
|
61
|
+
|
|
62
|
+
const steps = scenario.steps || [];
|
|
63
|
+
const tickStart = performance.now();
|
|
64
|
+
for (const step of steps) {
|
|
65
|
+
if (step.op === 'tick') {
|
|
66
|
+
for (let i = 0; i < step.n; i++) {
|
|
67
|
+
rec.tick(1);
|
|
68
|
+
clock.advance(TICK_MS);
|
|
69
|
+
}
|
|
70
|
+
} else if (step.op === 'fire') {
|
|
71
|
+
rec.fire(step.event, step.payload);
|
|
72
|
+
} else if (step.op === 'fireMany') {
|
|
73
|
+
for (let i = 0; i < (step.count || 1); i++) rec.fire(step.event, step.payload);
|
|
74
|
+
} else if (step.op === 'call') {
|
|
75
|
+
step.fn(rec);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (ticks > 0) {
|
|
79
|
+
for (let i = 0; i < ticks; i++) {
|
|
80
|
+
rec.tick(1);
|
|
81
|
+
clock.advance(TICK_MS);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
t.ticksMs = performance.now() - tickStart;
|
|
85
|
+
} finally {
|
|
86
|
+
globalThis.__opkRecorder = null;
|
|
87
|
+
clock.restore();
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
counters: rec.counters,
|
|
91
|
+
effects: rec.effects,
|
|
92
|
+
wall: t,
|
|
93
|
+
totalTicks: rec.tickCounter,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function compareCounters(a, b) {
|
|
98
|
+
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
|
99
|
+
return keys.map((k) => {
|
|
100
|
+
const av = a[k] || 0;
|
|
101
|
+
const bv = b[k] || 0;
|
|
102
|
+
const delta = bv - av;
|
|
103
|
+
const pct = av === 0 ? (bv === 0 ? 0 : 100) : Math.round((delta / av) * 100);
|
|
104
|
+
return { kind: k, original: av, optimized: bv, delta, pct };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Full bench: build the optimized pack, measure both variants, report.
|
|
110
|
+
*/
|
|
111
|
+
export async function benchPack(inputDirRaw, options = {}) {
|
|
112
|
+
const inputDir = path.resolve(inputDirRaw);
|
|
113
|
+
const {
|
|
114
|
+
ticks = 200,
|
|
115
|
+
entry = null,
|
|
116
|
+
scenario: scenarioPath = null,
|
|
117
|
+
quiet = false,
|
|
118
|
+
} = options;
|
|
119
|
+
|
|
120
|
+
const configRaw = { ...(options.config || {}) };
|
|
121
|
+
if (options.level) configRaw.level = options.level;
|
|
122
|
+
const config = normalizeConfig(configRaw);
|
|
123
|
+
ensureHooks();
|
|
124
|
+
const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'optipack-bench-'));
|
|
125
|
+
const report = runPack(inputDir, outputDir, config);
|
|
126
|
+
|
|
127
|
+
// Locate the entry: explicit, manifest, or first script with subscribers.
|
|
128
|
+
let entryRel = entry;
|
|
129
|
+
if (!entryRel) {
|
|
130
|
+
const manifestPath = path.join(inputDir, 'manifest.json');
|
|
131
|
+
if (fs.existsSync(manifestPath)) {
|
|
132
|
+
try {
|
|
133
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8').replace(/^\uFEFF/, ''));
|
|
134
|
+
const scriptModule = (manifest.modules || []).find((m) => m.type === 'script');
|
|
135
|
+
if (scriptModule && scriptModule.entry) entryRel = scriptModule.entry;
|
|
136
|
+
} catch { /* fall through */ }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!entryRel) {
|
|
140
|
+
const scripts = report.files.map((f) => f.path);
|
|
141
|
+
entryRel = scripts[0];
|
|
142
|
+
}
|
|
143
|
+
if (!entryRel) throw new Error('bench: pack has no script files');
|
|
144
|
+
if (!report.files.some((f) => f.path === entryRel)) {
|
|
145
|
+
throw new Error(`bench: entry "${entryRel}" is not among the pack's script files`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const scenario = scenarioPath
|
|
149
|
+
? JSON.parse(fs.readFileSync(scenarioPath, 'utf8').replace(/^\uFEFF/, ''))
|
|
150
|
+
: { reads: {}, state: {}, steps: [] };
|
|
151
|
+
|
|
152
|
+
const entryOriginal = path.join(inputDir, entryRel);
|
|
153
|
+
const entryOptimized = path.join(outputDir, entryRel);
|
|
154
|
+
|
|
155
|
+
// 3 alternating rounds per variant; report the median tick-phase time. A
|
|
156
|
+
// single round is dominated by V8 JIT warm-up noise (Node is only indicative
|
|
157
|
+
// anyway — see BENCHMARKING.md for the in-game workflow).
|
|
158
|
+
const rounds = 3;
|
|
159
|
+
const run = (v) => measureVariant(v === 'orig' ? entryOriginal : entryOptimized, v, scenario, ticks);
|
|
160
|
+
const aRuns = [];
|
|
161
|
+
const bRuns = [];
|
|
162
|
+
for (let i = 0; i < rounds; i++) {
|
|
163
|
+
aRuns.push(await run('orig'));
|
|
164
|
+
bRuns.push(await run('opt'));
|
|
165
|
+
}
|
|
166
|
+
const a = aRuns[0];
|
|
167
|
+
const b = bRuns[0];
|
|
168
|
+
a.wall.ticksMs = median(aRuns.map((r) => r.wall.ticksMs));
|
|
169
|
+
b.wall.ticksMs = median(bRuns.map((r) => r.wall.ticksMs));
|
|
170
|
+
|
|
171
|
+
let identical = true;
|
|
172
|
+
try {
|
|
173
|
+
assertSameEffects(a, b, 'bench');
|
|
174
|
+
} catch (e) {
|
|
175
|
+
identical = false;
|
|
176
|
+
if (!quiet) console.error(`optipack bench: WARNING — effect traces diverge, benchmark is not meaningful:\n${e.message}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const counters = compareCounters(a.counters, b.counters);
|
|
180
|
+
const totalCrossings = (v) => Object.values(v).reduce((x, y) => x + y, 0);
|
|
181
|
+
const result = {
|
|
182
|
+
pack: inputDir,
|
|
183
|
+
entry: entryRel,
|
|
184
|
+
ticks: a.totalTicks,
|
|
185
|
+
level: config.level,
|
|
186
|
+
effectsIdentical: identical,
|
|
187
|
+
boundary: counters,
|
|
188
|
+
boundaryTotals: { original: totalCrossings(a.counters), optimized: totalCrossings(b.counters) },
|
|
189
|
+
wall: {
|
|
190
|
+
original: a.wall,
|
|
191
|
+
optimized: b.wall,
|
|
192
|
+
note: 'Node/V8 numbers are indicative only; Minecraft runs QuickJS (no JIT).',
|
|
193
|
+
},
|
|
194
|
+
transforms: report.totals.transforms,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
if (!quiet) printBench(result);
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function fmtPct(pct) {
|
|
202
|
+
const s = pct > 0 ? `+${pct}%` : `${pct}%`;
|
|
203
|
+
return s;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function median(values) {
|
|
207
|
+
const sorted = [...values].sort((x, y) => x - y);
|
|
208
|
+
const mid = Math.floor(sorted.length / 2);
|
|
209
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function printBench(result) {
|
|
213
|
+
const rows = result.boundary;
|
|
214
|
+
const width = Math.max(10, ...rows.map((r) => r.kind.length));
|
|
215
|
+
console.log(`optipack bench — ${result.pack} (entry ${result.entry}, ${result.ticks} ticks, level ${result.level})`);
|
|
216
|
+
if (!result.effectsIdentical) {
|
|
217
|
+
console.log(' !! effect traces DIVERGE — the two builds are not behavior-equivalent; treat numbers with suspicion');
|
|
218
|
+
}
|
|
219
|
+
console.log(` ${'boundary crossing'.padEnd(width)} original optimized delta`);
|
|
220
|
+
for (const r of rows) {
|
|
221
|
+
console.log(` ${r.kind.padEnd(width)} ${String(r.original).padStart(8)} ${String(r.optimized).padStart(9)} ${fmtPct(r.pct)}`);
|
|
222
|
+
}
|
|
223
|
+
const tot = result.boundaryTotals;
|
|
224
|
+
const totalPct = tot.original === 0 ? 0 : Math.round(((tot.optimized - tot.original) / tot.original) * 100);
|
|
225
|
+
console.log(` ${'TOTAL'.padEnd(width)} ${String(tot.original).padStart(8)} ${String(tot.optimized).padStart(9)} ${fmtPct(totalPct)}`);
|
|
226
|
+
const w = result.wall;
|
|
227
|
+
const usPerTick = (v) => (result.ticks > 0 ? Math.round((v.ticksMs / result.ticks) * 1000) : 0);
|
|
228
|
+
console.log(` transforms applied: ${result.transforms}`);
|
|
229
|
+
console.log(` wall (indicative, Node/V8): original ${w.original.ticksMs.toFixed(1)}ms (${usPerTick(w.original)}µs/tick)` +
|
|
230
|
+
`, optimized ${w.optimized.ticksMs.toFixed(1)}ms (${usPerTick(w.optimized)}µs/tick)`);
|
|
231
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
2
|
* optipack CLI.
|
|
3
3
|
*
|
|
4
4
|
* optipack <inputPackDir> <outputPackDir> [options]
|
|
5
|
+
* optipack bench <inputPackDir> [options]
|
|
5
6
|
*
|
|
6
|
-
* Exit codes: 0 success
|
|
7
|
+
* Exit codes: 0 success · 1 usage/config error · 2 parse failure · 3 internal error
|
|
7
8
|
*/
|
|
8
9
|
import fs from 'node:fs';
|
|
9
10
|
import path from 'node:path';
|
|
@@ -14,47 +15,31 @@ import { runPack, ParseFailureError, InternalError } from './pipeline.js';
|
|
|
14
15
|
import { PASS_IDS, PASS_CODES } from './passes/index.js';
|
|
15
16
|
import { unifiedDiff } from './diff.js';
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
.argument('<inputPackDir>', 'behavior pack directory to read')
|
|
24
|
-
.argument('<outputPackDir>', 'directory to write the optimized pack into')
|
|
25
|
-
.option('--level <level>', 'optimization level: safe | balanced | risky')
|
|
26
|
-
.option('--config <path>', 'config file (default: ./optipack.config.json when present)')
|
|
27
|
-
.option('--only <ids>', 'comma-separated transform ids to run exclusively')
|
|
28
|
-
.option('--except <ids>', 'comma-separated transform ids to skip')
|
|
29
|
-
.option('--dry-run', 'analyze + report, write nothing (prints report to stdout unless --report is given)')
|
|
30
|
-
.option('--diff', 'print unified diffs per changed file')
|
|
31
|
-
.option('--report <path>', `report file (default: <outputPackDir>/optipack-report.json)`)
|
|
32
|
-
.option('--lint <bool>', 'enable/disable the lint pass (true|false)', parseBoolOpt)
|
|
33
|
-
.exitOverride();
|
|
18
|
+
function parseBoolOpt(v) {
|
|
19
|
+
if (v === 'true' || v === '' || v === undefined) return true;
|
|
20
|
+
if (v === 'false') return false;
|
|
21
|
+
// Signal an invalid value; validated by the caller.
|
|
22
|
+
return v;
|
|
23
|
+
}
|
|
34
24
|
|
|
35
|
-
|
|
36
|
-
const
|
|
25
|
+
function parseOptInt(v) {
|
|
26
|
+
const n = Number.parseInt(v, 10);
|
|
27
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function runOptimize(inputDir, outputDir, opts) {
|
|
37
31
|
try {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
const exit = await runOptimizeInner(inputDir, outputDir, opts);
|
|
33
|
+
process.exitCode = exit;
|
|
34
|
+
return exit;
|
|
41
35
|
} catch (e) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
if (e && (e.code === 'commander.help' || e.code === 'commander.helpDisplayed')) {
|
|
47
|
-
return 0;
|
|
48
|
-
}
|
|
49
|
-
if (e && e.code === 'commander.missingArgument') {
|
|
50
|
-
console.error(`optipack: ${e.message}`);
|
|
51
|
-
return 1;
|
|
52
|
-
}
|
|
53
|
-
console.error(`optipack: ${e && e.message ? e.message : String(e)}`);
|
|
54
|
-
return 1;
|
|
36
|
+
process.exitCode = 3;
|
|
37
|
+
console.error(`optipack: internal error — ${e && e.stack ? e.stack : String(e)}`);
|
|
38
|
+
return 3;
|
|
55
39
|
}
|
|
40
|
+
}
|
|
56
41
|
|
|
57
|
-
|
|
42
|
+
async function runOptimizeInner(inputDir, outputDir, opts) {
|
|
58
43
|
if (!inputDir || !outputDir) {
|
|
59
44
|
console.error('optipack: both <inputPackDir> and <outputPackDir> are required');
|
|
60
45
|
return 1;
|
|
@@ -84,7 +69,6 @@ export async function main(argv) {
|
|
|
84
69
|
return 1;
|
|
85
70
|
}
|
|
86
71
|
|
|
87
|
-
// CLI overrides.
|
|
88
72
|
const cli = {
|
|
89
73
|
level: opts.level,
|
|
90
74
|
only: opts.only,
|
|
@@ -122,7 +106,7 @@ export async function main(argv) {
|
|
|
122
106
|
if (opts.report !== undefined) {
|
|
123
107
|
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
|
124
108
|
fs.writeFileSync(reportPath, json);
|
|
125
|
-
console.log(`optipack: dry run
|
|
109
|
+
console.log(`optipack: dry run — wrote report to ${reportPath} (no pack files written)`);
|
|
126
110
|
} else {
|
|
127
111
|
console.log(json);
|
|
128
112
|
}
|
|
@@ -144,7 +128,6 @@ export async function main(argv) {
|
|
|
144
128
|
}
|
|
145
129
|
}
|
|
146
130
|
|
|
147
|
-
// Human summary on success (only when not printing the full report).
|
|
148
131
|
if (!dryRun || opts.report !== undefined) {
|
|
149
132
|
const t = report.totals;
|
|
150
133
|
console.log(
|
|
@@ -156,25 +139,107 @@ export async function main(argv) {
|
|
|
156
139
|
return 0;
|
|
157
140
|
} catch (e) {
|
|
158
141
|
if (e instanceof ParseFailureError) {
|
|
159
|
-
console.error(`optipack: parse failure
|
|
142
|
+
console.error(`optipack: parse failure — ${e.message}`);
|
|
160
143
|
return 2;
|
|
161
144
|
}
|
|
162
145
|
if (e instanceof InternalError) {
|
|
163
|
-
console.error(`optipack: internal error
|
|
146
|
+
console.error(`optipack: internal error — ${e.message}`);
|
|
164
147
|
return 3;
|
|
165
148
|
}
|
|
166
149
|
if (e && e.code === 1) {
|
|
167
150
|
console.error(`optipack: ${e.message}`);
|
|
168
151
|
return 1;
|
|
169
152
|
}
|
|
170
|
-
console.error(`optipack: internal error
|
|
153
|
+
console.error(`optipack: internal error — ${e && e.stack ? e.stack : String(e)}`);
|
|
171
154
|
return 3;
|
|
172
155
|
}
|
|
173
156
|
}
|
|
174
157
|
|
|
175
|
-
function
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
158
|
+
async function runBench(packDir, opts) {
|
|
159
|
+
const run = async () => {
|
|
160
|
+
const { benchPack } = await import('./bench.js');
|
|
161
|
+
const result = await benchPack(packDir, {
|
|
162
|
+
ticks: opts.ticks,
|
|
163
|
+
entry: opts.entry,
|
|
164
|
+
scenario: opts.scenario,
|
|
165
|
+
config: opts.config && JSON.parse(fs.readFileSync(opts.config, 'utf8').replace(/^\uFEFF/, '')),
|
|
166
|
+
level: opts.level,
|
|
167
|
+
});
|
|
168
|
+
if (opts.json) {
|
|
169
|
+
fs.mkdirSync(path.dirname(opts.json), { recursive: true });
|
|
170
|
+
fs.writeFileSync(opts.json, JSON.stringify(result, null, 2) + '\n');
|
|
171
|
+
console.log(`optipack bench: JSON written to ${opts.json}`);
|
|
172
|
+
}
|
|
173
|
+
return result.effectsIdentical ? 0 : 1;
|
|
174
|
+
};
|
|
175
|
+
try {
|
|
176
|
+
const code = await run();
|
|
177
|
+
process.exitCode = code;
|
|
178
|
+
return code;
|
|
179
|
+
} catch (e) {
|
|
180
|
+
const code = e instanceof ParseFailureError ? 2
|
|
181
|
+
: e instanceof InternalError ? 3
|
|
182
|
+
: 1;
|
|
183
|
+
console.error(`optipack bench: ${e instanceof ParseFailureError || e instanceof InternalError ? e.message : (e && e.message ? e.message : String(e))}`);
|
|
184
|
+
process.exitCode = code;
|
|
185
|
+
return code;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function main(argv) {
|
|
190
|
+
const program = new Command();
|
|
191
|
+
program
|
|
192
|
+
.name('optipack')
|
|
193
|
+
.description('QuickJS-targeted micro-optimizing pre-processor for Minecraft Bedrock behavior-pack scripts')
|
|
194
|
+
.version('1.0.0')
|
|
195
|
+
.exitOverride()
|
|
196
|
+
.configureOutput({ writeErr: () => {} }); // actions print errors themselves
|
|
197
|
+
|
|
198
|
+
program
|
|
199
|
+
.command('bench')
|
|
200
|
+
.description('A/B benchmark: original vs optimized scripts under the harness mock (boundary crossings + indicative wall time). See BENCHMARKING.md for the in-game workflow.')
|
|
201
|
+
.argument('<inputPackDir>', 'behavior pack directory to benchmark')
|
|
202
|
+
.option('--ticks <n>', 'simulated ticks to run (default 200)', parseOptInt)
|
|
203
|
+
.option('--entry <path>', 'script entry to load (default: manifest script entry)')
|
|
204
|
+
.option('--scenario <path>', 'scenario JSON (reads/state/steps) driving events between ticks')
|
|
205
|
+
.option('--json <path>', 'also write the bench result as JSON to this path')
|
|
206
|
+
.option('--level <level>', 'optimization level for the optimized build')
|
|
207
|
+
.option('--config <path>', 'config file for the optimized build')
|
|
208
|
+
.action(function (packDir, cmdOpts, command) {
|
|
209
|
+
// --level is declared on both this subcommand and the default command;
|
|
210
|
+
// Commander absorbs shadowed names into the parent scope, so merge.
|
|
211
|
+
return runBench(packDir, command.optsWithGlobals());
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
program
|
|
215
|
+
.argument('<inputPackDir>', 'behavior pack directory to read')
|
|
216
|
+
.argument('<outputPackDir>', 'directory to write the optimized pack into')
|
|
217
|
+
.option('--level <level>', 'optimization level: safe | balanced | risky')
|
|
218
|
+
.option('--config <path>', 'config file (default: ./optipack.config.json when present)')
|
|
219
|
+
.option('--only <ids>', 'comma-separated transform ids to run exclusively')
|
|
220
|
+
.option('--except <ids>', 'comma-separated transform ids to skip')
|
|
221
|
+
.option('--dry-run', 'analyze + report, write nothing (prints report to stdout unless --report is given)')
|
|
222
|
+
.option('--diff', 'print unified diffs per changed file')
|
|
223
|
+
.option('--report <path>', 'report file (default: <outputPackDir>/optipack-report.json)')
|
|
224
|
+
.option('--lint <bool>', 'enable/disable the lint pass (true|false)', parseBoolOpt)
|
|
225
|
+
.action((inputDir, outputDir) => runOptimize(inputDir, outputDir, program.opts()));
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
await program.parseAsync(argv, { from: 'node' });
|
|
229
|
+
} catch (e) {
|
|
230
|
+
if (e && e.code === 'commander.version') {
|
|
231
|
+
console.log(e.message);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
if (e && (e.code === 'commander.help' || e.code === 'commander.helpDisplayed')) {
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
if (e && (e.code === 'commander.missingArgument' || e.code === 'commander.missingSubcommandArgument')) {
|
|
238
|
+
console.error(`optipack: ${e.message}`);
|
|
239
|
+
return 1;
|
|
240
|
+
}
|
|
241
|
+
console.error(`optipack: ${e && e.message ? e.message : String(e)}`);
|
|
242
|
+
return 1;
|
|
243
|
+
}
|
|
244
|
+
return process.exitCode ?? 0;
|
|
180
245
|
}
|
|
@@ -18,12 +18,15 @@ import os from 'node:os';
|
|
|
18
18
|
|
|
19
19
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
/** Register the ESM loader hooks (idempotent). Only needed for variant loading. */
|
|
22
|
+
export function ensureHooks() {
|
|
23
|
+
if (!globalThis.__opkHooksRegistered) {
|
|
24
|
+
register(
|
|
25
|
+
pathToFileURL(path.join(here, 'loader-hooks.mjs')).href,
|
|
26
|
+
pathToFileURL(path.join(here, 'driver.js')).href
|
|
27
|
+
);
|
|
28
|
+
globalThis.__opkHooksRegistered = true;
|
|
29
|
+
}
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
export function makeRecorder({ reads = {}, state = {} } = {}) {
|
|
@@ -37,6 +40,10 @@ export function makeRecorder({ reads = {}, state = {} } = {}) {
|
|
|
37
40
|
readIdx: {},
|
|
38
41
|
state,
|
|
39
42
|
tickCounter: 0,
|
|
43
|
+
counters: {},
|
|
44
|
+
};
|
|
45
|
+
r.count = (kind, n = 1) => {
|
|
46
|
+
r.counters[kind] = (r.counters[kind] || 0) + n;
|
|
40
47
|
};
|
|
41
48
|
r.effect = (call, args) => {
|
|
42
49
|
r.effects.push({ call, args });
|
|
@@ -105,7 +112,7 @@ export function makeRecorder({ reads = {}, state = {} } = {}) {
|
|
|
105
112
|
return r;
|
|
106
113
|
}
|
|
107
114
|
|
|
108
|
-
/** Recursively replace {__entity
|
|
115
|
+
/** Recursively replace {__entity}/{__dimension}/{__block} markers in a payload with live mock objects. */
|
|
109
116
|
function hydrate(value, rec) {
|
|
110
117
|
if (globalThis.__opkEnsurePlayers) globalThis.__opkEnsurePlayers(rec);
|
|
111
118
|
if (value === null || typeof value !== 'object') return value;
|
|
@@ -115,6 +122,14 @@ function hydrate(value, rec) {
|
|
|
115
122
|
const found = players.find((p) => p.id === value.__entity);
|
|
116
123
|
return found || value;
|
|
117
124
|
}
|
|
125
|
+
if (typeof value.__dimension === 'string') {
|
|
126
|
+
if (!globalThis.__opkMakeDimension) throw new Error('hydrate __dimension: server mock not loaded');
|
|
127
|
+
return globalThis.__opkMakeDimension(value.__dimension);
|
|
128
|
+
}
|
|
129
|
+
if (value.__block) {
|
|
130
|
+
if (!globalThis.__opkMakeBlock) throw new Error('hydrate __block: server mock not loaded');
|
|
131
|
+
return globalThis.__opkMakeBlock(hydrate(value.__block.location, rec), value.__block.dimension);
|
|
132
|
+
}
|
|
118
133
|
const out = {};
|
|
119
134
|
for (const k of Object.keys(value)) out[k] = hydrate(value[k], rec);
|
|
120
135
|
return out;
|
|
@@ -125,6 +140,7 @@ function hydrate(value, rec) {
|
|
|
125
140
|
* Steps: {op:'tick', n}, {op:'fire', event, payload}, {op:'call', fn(rec)}
|
|
126
141
|
*/
|
|
127
142
|
export async function runVariant(entryUrl, variant, { reads, state, steps = [] }) {
|
|
143
|
+
ensureHooks();
|
|
128
144
|
const rec = makeRecorder({ reads, state });
|
|
129
145
|
globalThis.__opkRecorder = rec;
|
|
130
146
|
try {
|
|
@@ -132,7 +148,9 @@ export async function runVariant(entryUrl, variant, { reads, state, steps = [] }
|
|
|
132
148
|
for (const step of steps) {
|
|
133
149
|
if (step.op === 'tick') rec.tick(step.n);
|
|
134
150
|
else if (step.op === 'fire') rec.fire(step.event, step.payload);
|
|
135
|
-
else if (step.op === '
|
|
151
|
+
else if (step.op === 'fireMany') {
|
|
152
|
+
for (let i = 0; i < (step.count || 1); i++) rec.fire(step.event, step.payload);
|
|
153
|
+
} else if (step.op === 'call') step.fn(rec);
|
|
136
154
|
}
|
|
137
155
|
} finally {
|
|
138
156
|
globalThis.__opkRecorder = null;
|
|
@@ -164,8 +182,8 @@ export function assertSameEffects(a, b, label = '') {
|
|
|
164
182
|
* the original and optimized file paths.
|
|
165
183
|
*/
|
|
166
184
|
export async function buildVariantPack(files, config, testSuffix) {
|
|
167
|
-
const { normalizeConfig } = await import('
|
|
168
|
-
const { runPack } = await import('
|
|
185
|
+
const { normalizeConfig } = await import('../config.js');
|
|
186
|
+
const { runPack } = await import('../pipeline.js');
|
|
169
187
|
const inputDir = fs.mkdtempSync(path.join(os.tmpdir(), `optipack-${testSuffix}-in-`));
|
|
170
188
|
const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), `optipack-${testSuffix}-out-`));
|
|
171
189
|
for (const [relPath, content] of Object.entries(files)) {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node module loader hooks for differential testing.
|
|
3
|
+
*
|
|
4
|
+
* - `@minecraft/*` runtime modules resolve to harness mocks under
|
|
5
|
+
* test/mock/@minecraft/ (single instances; the recorder is passed via
|
|
6
|
+
* globalThis.__opkRecorder). @minecraft/server and @minecraft/server-ui
|
|
7
|
+
* have functional mocks; the remaining runtime modules have permissive
|
|
8
|
+
* stubs generated from their real type definitions.
|
|
9
|
+
* - Pack files are loaded with a `?opkv=<variant>` query so the original and
|
|
10
|
+
* optimized variants get separate ESM cache entries; the query is stripped
|
|
11
|
+
* when reading from disk and propagated to relative imports.
|
|
12
|
+
*/
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const mockDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'mock', '@minecraft');
|
|
17
|
+
|
|
18
|
+
// Functional mocks.
|
|
19
|
+
const FUNCTIONAL = new Set(['@minecraft/server', '@minecraft/server-ui']);
|
|
20
|
+
|
|
21
|
+
// Permissive stubs generated from each module's real index.d.ts.
|
|
22
|
+
const STUBBED = new Set([
|
|
23
|
+
'@minecraft/common',
|
|
24
|
+
'@minecraft/debug-utilities',
|
|
25
|
+
'@minecraft/diagnostics',
|
|
26
|
+
'@minecraft/server-admin',
|
|
27
|
+
'@minecraft/server-editor',
|
|
28
|
+
'@minecraft/server-gametest',
|
|
29
|
+
'@minecraft/server-graphics',
|
|
30
|
+
'@minecraft/server-net',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const MOCK_URLS = new Map([
|
|
34
|
+
['@minecraft/server', pathToFileURL(path.join(mockDir, 'server.mjs')).href],
|
|
35
|
+
['@minecraft/server-ui', pathToFileURL(path.join(mockDir, 'server-ui.mjs')).href],
|
|
36
|
+
...[...STUBBED].map((name) => [
|
|
37
|
+
name,
|
|
38
|
+
pathToFileURL(path.join(mockDir, name.replace('@minecraft/', '').replace(/\//g, '-') + '.mjs')).href,
|
|
39
|
+
]),
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
43
|
+
if (MOCK_URLS.has(specifier)) {
|
|
44
|
+
return { url: MOCK_URLS.get(specifier), shortCircuit: true };
|
|
45
|
+
}
|
|
46
|
+
const res = await nextResolve(specifier, context);
|
|
47
|
+
const parentURL = context.parentURL || '';
|
|
48
|
+
if (parentURL.includes('opkv=') && res.url.startsWith('file:') && !res.url.includes('opkv=')) {
|
|
49
|
+
const q = new URL(parentURL).search;
|
|
50
|
+
return { ...res, url: res.url + q, shortCircuit: true };
|
|
51
|
+
}
|
|
52
|
+
return res;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function load(url, context, nextLoad) {
|
|
56
|
+
const u = new URL(url);
|
|
57
|
+
if (u.protocol === 'file:' && u.search) {
|
|
58
|
+
const clean = pathToFileURL(fileURLToPath(u)).href;
|
|
59
|
+
const r = await nextLoad(clean, context);
|
|
60
|
+
return { format: r.format || 'module', source: r.source, shortCircuit: true };
|
|
61
|
+
}
|
|
62
|
+
return nextLoad(url, context);
|
|
63
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permissive stub of @minecraft/common@1.3.0 for the differential harness.
|
|
3
|
+
* Generated from the module's real index.d.ts (see test/update-mock-stubs.mjs).
|
|
4
|
+
* Every runtime export is a permissive value: callable, constructable, and
|
|
5
|
+
* chainable; calls and constructions record harness effects so differential
|
|
6
|
+
* equivalence holds. NOT a behavioral model of the engine module.
|
|
7
|
+
*/
|
|
8
|
+
import { permissive } from '../permissive.mjs';
|
|
9
|
+
|
|
10
|
+
export const ArgumentOutOfBoundsError = permissive('common.ArgumentOutOfBoundsError');
|
|
11
|
+
export const ContainerSizeOutOfBoundsError = permissive('common.ContainerSizeOutOfBoundsError');
|
|
12
|
+
export const EngineError = permissive('common.EngineError');
|
|
13
|
+
export const InvalidArgumentError = permissive('common.InvalidArgumentError');
|
|
14
|
+
export const InvalidArgumentErrorType = permissive('common.InvalidArgumentErrorType');
|
|
15
|
+
export const PropertyOutOfBoundsError = permissive('common.PropertyOutOfBoundsError');
|
|
16
|
+
export const RuntimeConditionError = permissive('common.RuntimeConditionError');
|
|
17
|
+
export const UnsupportedFunctionalityError = permissive('common.UnsupportedFunctionalityError');
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permissive stub of @minecraft/debug-utilities@1.0.0-beta.1.20.70-preview.20 for the differential harness.
|
|
3
|
+
* Generated from the module's real index.d.ts (see test/update-mock-stubs.mjs).
|
|
4
|
+
* Every runtime export is a permissive value: callable, constructable, and
|
|
5
|
+
* chainable; calls and constructions record harness effects so differential
|
|
6
|
+
* equivalence holds. NOT a behavioral model of the engine module.
|
|
7
|
+
*/
|
|
8
|
+
import { permissive } from '../permissive.mjs';
|
|
9
|
+
|
|
10
|
+
export const disableWatchdog = permissive('debug-utilities.disableWatchdog');
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permissive stub of @minecraft/diagnostics@1.0.0-beta.1.21.70-preview.22 for the differential harness.
|
|
3
|
+
* Generated from the module's real index.d.ts (see test/update-mock-stubs.mjs).
|
|
4
|
+
* Every runtime export is a permissive value: callable, constructable, and
|
|
5
|
+
* chainable; calls and constructions record harness effects so differential
|
|
6
|
+
* equivalence holds. NOT a behavioral model of the engine module.
|
|
7
|
+
*/
|
|
8
|
+
import { permissive } from '../permissive.mjs';
|
|
9
|
+
|
|
10
|
+
export const Sentry = permissive('diagnostics.Sentry');
|
|
11
|
+
export const SentryAlreadyInitializedError = permissive('diagnostics.SentryAlreadyInitializedError');
|
|
12
|
+
export const SentryBreadcrumbLevel = permissive('diagnostics.SentryBreadcrumbLevel');
|
|
13
|
+
export const SentryUninitializedError = permissive('diagnostics.SentryUninitializedError');
|
|
14
|
+
export const sentry = permissive('diagnostics.sentry');
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permissive stub of @minecraft/server-admin@1.0.0-beta.11940b24 for the differential harness.
|
|
3
|
+
* Generated from the module's real index.d.ts (see test/update-mock-stubs.mjs).
|
|
4
|
+
* Every runtime export is a permissive value: callable, constructable, and
|
|
5
|
+
* chainable; calls and constructions record harness effects so differential
|
|
6
|
+
* equivalence holds. NOT a behavioral model of the engine module.
|
|
7
|
+
*/
|
|
8
|
+
import { permissive } from '../permissive.mjs';
|
|
9
|
+
|
|
10
|
+
export const SecretString = permissive('server-admin.SecretString');
|
|
11
|
+
export const ServerSecrets = permissive('server-admin.ServerSecrets');
|
|
12
|
+
export const ServerVariables = permissive('server-admin.ServerVariables');
|
|
13
|
+
export const secrets = permissive('server-admin.secrets');
|
|
14
|
+
export const variables = permissive('server-admin.variables');
|