@driftengine/core 3.61.1 → 3.61.2
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/CHANGELOG.json +11 -0
- package/package.json +4 -3
- package/scripts/browser.d.mts +17 -0
- package/scripts/browser.mjs +353 -0
- package/scripts/cdp.d.mts +28 -0
- package/scripts/cdp.mjs +320 -0
- package/scripts/frames.mjs +163 -0
- package/scripts/heldClock.mjs +98 -0
- package/scripts/mesh-bench.ts +71 -0
- package/scripts/png.d.mts +17 -0
- package/scripts/png.mjs +201 -0
- package/scripts/releases.mjs +109 -0
package/scripts/png.mjs
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enough of a PNG reader to compare two frames, with no dependency.
|
|
3
|
+
*
|
|
4
|
+
* A screenshot arrives as 8-bit RGBA, filter method 0, not interlaced, which is what every
|
|
5
|
+
* browser produces and is exactly what this handles. Anything else is refused by name rather
|
|
6
|
+
* than guessed at, because a reader that quietly mis-decodes produces pixel differences that
|
|
7
|
+
* look like a rendering change.
|
|
8
|
+
*
|
|
9
|
+
* `zlib` is Node's own, so this costs nothing to have.
|
|
10
|
+
*/
|
|
11
|
+
import { crc32, deflateSync, inflateSync } from 'node:zlib';
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
|
|
14
|
+
const SIGNATURE = 0x89504e47;
|
|
15
|
+
|
|
16
|
+
/** `{ width, height, channels, pixels }`, pixels being row-major 8-bit samples. */
|
|
17
|
+
export function decodePng(bytes) {
|
|
18
|
+
if (bytes.length < 8 || bytes.readUInt32BE(0) !== SIGNATURE) throw new Error('not a PNG');
|
|
19
|
+
|
|
20
|
+
let at = 8;
|
|
21
|
+
let width = 0;
|
|
22
|
+
let height = 0;
|
|
23
|
+
let channels = 0;
|
|
24
|
+
const parts = [];
|
|
25
|
+
|
|
26
|
+
while (at + 8 <= bytes.length) {
|
|
27
|
+
const length = bytes.readUInt32BE(at);
|
|
28
|
+
const type = bytes.toString('ascii', at + 4, at + 8);
|
|
29
|
+
const body = bytes.subarray(at + 8, at + 8 + length);
|
|
30
|
+
if (type === 'IHDR') {
|
|
31
|
+
width = body.readUInt32BE(0);
|
|
32
|
+
height = body.readUInt32BE(4);
|
|
33
|
+
const depth = body[8];
|
|
34
|
+
const colorType = body[9];
|
|
35
|
+
const interlace = body[12];
|
|
36
|
+
if (depth !== 8) throw new Error(`bit depth ${depth}, only 8 is handled`);
|
|
37
|
+
if (interlace !== 0) throw new Error('interlaced PNG, not handled');
|
|
38
|
+
channels = colorType === 6 ? 4 : colorType === 2 ? 3 : 0;
|
|
39
|
+
if (channels === 0) throw new Error(`colour type ${colorType}, only 2 and 6 are handled`);
|
|
40
|
+
} else if (type === 'IDAT') {
|
|
41
|
+
parts.push(body);
|
|
42
|
+
} else if (type === 'IEND') {
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
/* length, type, body, CRC. */
|
|
46
|
+
at += 12 + length;
|
|
47
|
+
}
|
|
48
|
+
if (width === 0 || height === 0) throw new Error('no IHDR');
|
|
49
|
+
|
|
50
|
+
const raw = inflateSync(Buffer.concat(parts));
|
|
51
|
+
const stride = width * channels;
|
|
52
|
+
const pixels = Buffer.alloc(stride * height);
|
|
53
|
+
|
|
54
|
+
/*
|
|
55
|
+
* Un-filtering, which is the whole of the format that is not zlib. Each row states one of five
|
|
56
|
+
* predictors and the residual against it; `a` is the sample one pixel left, `b` the one above,
|
|
57
|
+
* `c` the one above and left, and all three are zero outside the image rather than wrapped.
|
|
58
|
+
*/
|
|
59
|
+
for (let y = 0; y < height; y++) {
|
|
60
|
+
const filter = raw[y * (stride + 1)];
|
|
61
|
+
const from = y * (stride + 1) + 1;
|
|
62
|
+
const to = y * stride;
|
|
63
|
+
for (let i = 0; i < stride; i++) {
|
|
64
|
+
const value = raw[from + i];
|
|
65
|
+
const a = i >= channels ? pixels[to + i - channels] : 0;
|
|
66
|
+
const b = y > 0 ? pixels[to - stride + i] : 0;
|
|
67
|
+
const c = i >= channels && y > 0 ? pixels[to - stride + i - channels] : 0;
|
|
68
|
+
let restored;
|
|
69
|
+
if (filter === 0) restored = value;
|
|
70
|
+
else if (filter === 1) restored = value + a;
|
|
71
|
+
else if (filter === 2) restored = value + b;
|
|
72
|
+
else if (filter === 3) restored = value + ((a + b) >> 1);
|
|
73
|
+
else if (filter === 4) restored = value + paeth(a, b, c);
|
|
74
|
+
else throw new Error(`row ${y} states filter ${filter}, which is not a PNG filter`);
|
|
75
|
+
pixels[to + i] = restored & 0xff;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { width, height, channels, pixels };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The Paeth predictor: whichever of the three neighbours the gradient points closest to. */
|
|
83
|
+
function paeth(a, b, c) {
|
|
84
|
+
const p = a + b - c;
|
|
85
|
+
const pa = Math.abs(p - a);
|
|
86
|
+
const pb = Math.abs(p - b);
|
|
87
|
+
const pc = Math.abs(p - c);
|
|
88
|
+
if (pa <= pb && pa <= pc) return a;
|
|
89
|
+
return pb <= pc ? b : c;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function readPng(path) {
|
|
93
|
+
return decodePng(readFileSync(path));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Write 8-bit RGBA as a PNG. The other half of this file, and it arrived nine months later.
|
|
98
|
+
*
|
|
99
|
+
* **Why the baker needs it.** A block-compressed DDS is decoded so a bought model's maps survive
|
|
100
|
+
* the bake, and the decoded surface was then embedded as `CODEC_RAW`, because nothing on the Node
|
|
101
|
+
* side could write an image back. Measured on one shipped car's level of detail B: 21 textures,
|
|
102
|
+
* 3.6 MB of source, 11.6 MB of RGBA in a 22.8 MB container. Both ends are lossless, so this is a
|
|
103
|
+
* smaller file and not a worse one.
|
|
104
|
+
*
|
|
105
|
+
* **`node:zlib` is the whole dependency**, which is what makes this affordable — it runs offline,
|
|
106
|
+
* in a tool, once per bake, and a game never links it.
|
|
107
|
+
*
|
|
108
|
+
* Colour type 6 only, because the one caller decodes to RGBA and a second colour type is a second
|
|
109
|
+
* set of filter arithmetic to get wrong for no measured gain.
|
|
110
|
+
*/
|
|
111
|
+
export function encodePng(width, height, rgba) {
|
|
112
|
+
const stride = width * 4;
|
|
113
|
+
const expected = stride * height;
|
|
114
|
+
if (rgba.length !== expected) {
|
|
115
|
+
throw new Error(`${width}x${height} RGBA is ${expected} bytes, got ${rgba.length}`);
|
|
116
|
+
}
|
|
117
|
+
const pixels = Buffer.isBuffer(rgba)
|
|
118
|
+
? rgba
|
|
119
|
+
: Buffer.from(rgba.buffer, rgba.byteOffset, rgba.length);
|
|
120
|
+
|
|
121
|
+
/*
|
|
122
|
+
* One filter chosen per row by the sum of absolute residuals, which is the heuristic the format's
|
|
123
|
+
* own specification suggests and every encoder uses. It is a heuristic and not an optimum: it
|
|
124
|
+
* assumes the deflate that follows is cheapest on the bytes nearest zero, which is true often
|
|
125
|
+
* enough to be worth the five passes and is not a claim about any particular image.
|
|
126
|
+
*/
|
|
127
|
+
const raw = Buffer.alloc((stride + 1) * height);
|
|
128
|
+
const candidate = Buffer.alloc(stride);
|
|
129
|
+
for (let y = 0; y < height; y++) {
|
|
130
|
+
const row = y * stride;
|
|
131
|
+
const prev = row - stride;
|
|
132
|
+
const out = y * (stride + 1);
|
|
133
|
+
let bestFilter = 0;
|
|
134
|
+
let bestScore = Infinity;
|
|
135
|
+
for (let filter = 0; filter <= 4; filter++) {
|
|
136
|
+
let score = 0;
|
|
137
|
+
for (let i = 0; i < stride; i++) {
|
|
138
|
+
const a = i >= 4 ? pixels[row + i - 4] : 0;
|
|
139
|
+
const b = y > 0 ? pixels[prev + i] : 0;
|
|
140
|
+
const c = i >= 4 && y > 0 ? pixels[prev + i - 4] : 0;
|
|
141
|
+
const residual = (pixels[row + i] - predict(filter, a, b, c)) & 0xff;
|
|
142
|
+
/* Distance from zero in both directions: 255 is a residual of one, not of 255. */
|
|
143
|
+
score += residual < 128 ? residual : 256 - residual;
|
|
144
|
+
}
|
|
145
|
+
if (score < bestScore) {
|
|
146
|
+
bestScore = score;
|
|
147
|
+
bestFilter = filter;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
for (let i = 0; i < stride; i++) {
|
|
151
|
+
const a = i >= 4 ? pixels[row + i - 4] : 0;
|
|
152
|
+
const b = y > 0 ? pixels[prev + i] : 0;
|
|
153
|
+
const c = i >= 4 && y > 0 ? pixels[prev + i - 4] : 0;
|
|
154
|
+
candidate[i] = (pixels[row + i] - predict(bestFilter, a, b, c)) & 0xff;
|
|
155
|
+
}
|
|
156
|
+
raw[out] = bestFilter;
|
|
157
|
+
candidate.copy(raw, out + 1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const ihdr = Buffer.alloc(13);
|
|
161
|
+
ihdr.writeUInt32BE(width, 0);
|
|
162
|
+
ihdr.writeUInt32BE(height, 4);
|
|
163
|
+
ihdr[8] = 8;
|
|
164
|
+
ihdr[9] = 6;
|
|
165
|
+
ihdr[10] = 0;
|
|
166
|
+
ihdr[11] = 0;
|
|
167
|
+
ihdr[12] = 0;
|
|
168
|
+
|
|
169
|
+
return Buffer.concat([
|
|
170
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
171
|
+
chunk('IHDR', ihdr),
|
|
172
|
+
chunk('IDAT', deflateSync(raw, { level: 9 })),
|
|
173
|
+
chunk('IEND', Buffer.alloc(0)),
|
|
174
|
+
]);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The predictor a filter subtracts, with `predict(4, …)` being Paeth and `predict(0, …)` nothing. */
|
|
178
|
+
function predict(filter, a, b, c) {
|
|
179
|
+
if (filter === 0) return 0;
|
|
180
|
+
if (filter === 1) return a;
|
|
181
|
+
if (filter === 2) return b;
|
|
182
|
+
if (filter === 3) return (a + b) >> 1;
|
|
183
|
+
return paeth(a, b, c);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Length, type, body, CRC.
|
|
188
|
+
*
|
|
189
|
+
* **The CRC covers the type and the body and not the length**, and it is the one part of this that
|
|
190
|
+
* `decodePng` above cannot check the work of, because it does not read one. A browser does, at
|
|
191
|
+
* `createImageBitmap`, and refuses the file — so an encoder verified only against this file's own
|
|
192
|
+
* reader would pass every test and produce images nothing else would open.
|
|
193
|
+
*/
|
|
194
|
+
function chunk(type, body) {
|
|
195
|
+
const out = Buffer.alloc(body.length + 12);
|
|
196
|
+
out.writeUInt32BE(body.length, 0);
|
|
197
|
+
out.write(type, 4, 'ascii');
|
|
198
|
+
body.copy(out, 8);
|
|
199
|
+
out.writeUInt32BE(crc32(out.subarray(4, 8 + body.length)), 8 + body.length);
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release notes, read and ordered in one place so every consumer shows the same list.
|
|
3
|
+
*
|
|
4
|
+
* **The engine's `CHANGELOG.json` is the source of truth and this is how it is read.** Four things
|
|
5
|
+
* display these notes — the game, the studio, the website and the engine's own site — and each used
|
|
6
|
+
* to parse and sort them itself, with a copy of the data pushed between repositories by hand after
|
|
7
|
+
* every release. One reader, one ordering, and a consumer regenerates from the engine at build time
|
|
8
|
+
* rather than waiting for somebody to republish.
|
|
9
|
+
*
|
|
10
|
+
* **A product's own history is ordered by version, never by date.** That is the bug this was
|
|
11
|
+
* written for: a date-first order put 2.5.0 above 2.6.0 because 2.5.0 carried a date somebody had
|
|
12
|
+
* guessed, so the list a player read disagreed with the numbers in it. A date is metadata and can
|
|
13
|
+
* be wrong; a version cannot.
|
|
14
|
+
*
|
|
15
|
+
* **Plain Node, beside `browser.mjs` and `cdp.mjs`**, because the things that read it are build
|
|
16
|
+
* scripts in four other repositories rather than anything a game bundles. `scripts/releases.test.mjs`
|
|
17
|
+
* in this repository is its suite.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* A release is `{ product, version, date, entries, reports? }`, and an entry is
|
|
21
|
+
* `{ kind, text, short? }`. Described here rather than declared: this is the shape four
|
|
22
|
+
* repositories read, and `releases.d.ts` in the design system types it for the ones that care.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Sortable rank for a semver string.
|
|
29
|
+
*
|
|
30
|
+
* Hand-rolled rather than taken from a dependency, and by *number* rather than by string — a
|
|
31
|
+
* string comparison puts `2.10.0` below `2.9.0`, which is the second ordering bug this file
|
|
32
|
+
* exists to make impossible.
|
|
33
|
+
*/
|
|
34
|
+
function rank(version) {
|
|
35
|
+
const match = SEMVER.exec(version);
|
|
36
|
+
if (match === null) throw new Error(`not a semver version: ${version}`);
|
|
37
|
+
return Number(match[1]) * 1_000_000 + Number(match[2]) * 1_000 + Number(match[3]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One product's releases, newest first, field by field.
|
|
42
|
+
*
|
|
43
|
+
* Copied field by field rather than spread, so a key added to a changelog has to be added here
|
|
44
|
+
* before it can reach a reader: this output is published, and a changelog is edited far more
|
|
45
|
+
* casually than a published surface deserves.
|
|
46
|
+
*/
|
|
47
|
+
export function parseChangelog(raw, product) {
|
|
48
|
+
if (!Array.isArray(raw)) throw new Error(`${product}: a changelog is an array of releases`);
|
|
49
|
+
|
|
50
|
+
const releases = raw.map((release) => {
|
|
51
|
+
if (typeof release.version !== 'string' || typeof release.date !== 'string') {
|
|
52
|
+
throw new Error(`${product}: a release needs a version and a date`);
|
|
53
|
+
}
|
|
54
|
+
rank(release.version);
|
|
55
|
+
|
|
56
|
+
const entries = Array.isArray(release.entries) ? release.entries : [];
|
|
57
|
+
const out = {
|
|
58
|
+
product,
|
|
59
|
+
version: release.version,
|
|
60
|
+
date: release.date,
|
|
61
|
+
entries: entries.map((entry) => {
|
|
62
|
+
const carried = {
|
|
63
|
+
kind: typeof entry.kind === 'string' ? entry.kind : 'changed',
|
|
64
|
+
text: typeof entry.text === 'string' ? entry.text : '',
|
|
65
|
+
};
|
|
66
|
+
return typeof entry.short === 'string' && entry.short.length > 0
|
|
67
|
+
? { ...carried, short: entry.short }
|
|
68
|
+
: carried;
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
return Array.isArray(release.reports) && release.reports.length > 0
|
|
72
|
+
? { ...out, reports: release.reports.map(String) }
|
|
73
|
+
: out;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return releases.sort((a, b) => rank(b.version) - rank(a.version));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Several products' releases as one list, newest first.
|
|
81
|
+
*
|
|
82
|
+
* **Across products there is nothing but the date to interleave by**, so that is what is used —
|
|
83
|
+
* but a date that would break a product's own version order is clamped rather than obeyed. A
|
|
84
|
+
* release cannot sort above a newer release of the same product, whatever its date says, which is
|
|
85
|
+
* what keeps a single wrong date from scrambling the whole list instead of just its own row.
|
|
86
|
+
*
|
|
87
|
+
* The displayed date is the authored one. Only the ordering is clamped, because showing a
|
|
88
|
+
* corrected date would hide the error rather than survive it.
|
|
89
|
+
*/
|
|
90
|
+
export function mergeReleases(products) {
|
|
91
|
+
const order = new Map();
|
|
92
|
+
for (const product of products) {
|
|
93
|
+
let ceiling = null;
|
|
94
|
+
/* Newest first already, so each release is clamped to the one above it. */
|
|
95
|
+
for (const release of product) {
|
|
96
|
+
const effective = ceiling === null || release.date <= ceiling ? release.date : ceiling;
|
|
97
|
+
order.set(release, effective);
|
|
98
|
+
ceiling = effective;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return products.flat().sort((a, b) => {
|
|
103
|
+
const dateA = order.get(a) ?? a.date;
|
|
104
|
+
const dateB = order.get(b) ?? b.date;
|
|
105
|
+
if (dateA !== dateB) return dateA < dateB ? 1 : -1;
|
|
106
|
+
if (a.product !== b.product) return a.product < b.product ? -1 : 1;
|
|
107
|
+
return rank(b.version) - rank(a.version);
|
|
108
|
+
});
|
|
109
|
+
}
|