Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions tools/bench-prefab-filter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Phase-level CPU benchmark of the block-name matching pipeline in
// src/worker/lib/prefab-filter.ts (#matchByBlockName equivalent), run directly
// under Deno so no browser / dev server is needed.
/**
* Phase-level CPU benchmark of the block-name matching pipeline in
* `src/worker/lib/prefab-filter.ts` (`#matchByBlockName` equivalent).
* Runs directly under Deno so no browser or dev server is needed.
*/
import {
matchAndHighlight,
MATCHED_BLOCKS_LIMIT,
Expand Down Expand Up @@ -145,7 +147,7 @@ function runOnce(pattern: RegExp) {
);
t.sort = performance.now() - t0;

// structuredClone approximates the postMessage serialize cost.
// WHY: structuredClone approximates the postMessage serialize cost.
t0 = performance.now();
structuredClone(result);
t.cloneAll = performance.now() - t0;
Expand Down
4 changes: 1 addition & 3 deletions tools/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,9 @@ if (args[0] === "serve") {

console.log(`Serving at http://${hosts[0]}:${port}`);

// Keep alive
await new Promise(() => {});
} else if (args[0] === "serve-static") {
// esbuild's serve drops response bodies under CI load, giving E2E 0-byte map
// files (issue 202); build once, then serve public/ from a real static server.
// WHY: esbuild's own serve drops response bodies under CI load, giving E2E 0-byte map files (see issue #202). Build once, then serve public/ from a real static server.
await esbuild.build({ ...commonOpts, ...serveOpts });
esbuild.stop();

Expand Down
8 changes: 5 additions & 3 deletions tools/copy-map-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import {
const WORLDS_DIR = vanillaDir("Data", "Worlds");
const DST_DIR = publishDir("maps");

// Concurrency for top-level world processing. Each world fans out into
// parallel per-file copies internally, so this value is intentionally small
// to avoid thrashing disk I/O across many large PNG/raw streams.
/**
* Concurrency for top-level world processing. Each world fans out into
* parallel per-file copies internally, so this value stays small to
* avoid thrashing disk I/O across many large PNG/raw streams.
*/
const WORLD_CONCURRENCY = 8;

async function main() {
Expand Down
21 changes: 12 additions & 9 deletions tools/extract-added-prefabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@ import {
writeJsonFile,
} from "./lib/utils.ts";

// index.json only tracks the prefabs of the latest game version; there is no
// tag-per-release history of it. Experimental versions also add/remove
// prefabs across many commits without ever bumping the version tag, so each
// version's added set is computed as (names at its `ref`) minus (names at
// the previous version's `ref`), not from a single tagged snapshot.
//
// The last entry is the in-progress version: its `ref` stays "HEAD" until
// that version is tagged, at which point set it to the new tag and append a
// new in-progress entry for the version after it.
/**
* `index.json` only tracks prefabs of the latest game version. There is
* no tag-per-release history, and experimental versions add or remove
* prefabs across many commits without ever bumping the version tag. So
* each version's added set is computed as (names at its `ref`) minus
* (names at the previous version's `ref`), not from a single tagged
* snapshot.
*
* The last entry is the in-progress version. Its `ref` stays `"HEAD"`
* until that version is tagged. On release, change it to the new tag
* and append a new in-progress entry for the version after it.
*/
const VERSIONS: readonly { version: string; ref: string }[] = [
{ version: "2.6", ref: "v2.6" },
{ version: "3.0", ref: "v3.0" },
Expand Down
2 changes: 1 addition & 1 deletion tools/generate-favicon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Resvg } from "@resvg/resvg-js";
import { handleMain, publishDir } from "./lib/utils.ts";
import { buildIco } from "./lib/ico.ts";

// Standard Windows/browser favicon resolutions.
/** Standard Windows/browser favicon resolutions. */
const SIZES = [16, 32, 48];

async function main() {
Expand Down
25 changes: 14 additions & 11 deletions tools/generate-font-subsets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@ interface FontJob {
dist: string;
}

// Each entry scans its `target` files for symbol/emoji characters and
// subsets only the glyphs its own font actually contains: pyftsubset drops
// codepoints missing from a font's cmap silently rather than erroring, so
// the same candidate character set can safely be offered to multiple fonts
// without cross-contamination (verified empirically before adopting this).
/**
* Font subset jobs. Each entry scans its `target` files for symbol
* and emoji characters and subsets only the glyphs its own font
* actually contains. `pyftsubset` silently drops codepoints missing
* from a font's cmap rather than erroring, so the same candidate
* character set can be offered to multiple fonts without
* cross-contamination.
*/
const JOBS: Readonly<Record<string, FontJob>> = {
"NotoColorEmoji-COLRv1.ttf": {
// DOM-facing source only. The ✘/🚩 map markers under src/worker/ stamp
// baked Path2D shapes (see tools/generate-glyph-markers.ts) in a
// specific solid marker color; this layered color font would never
// apply to them even if scanned.
// WHY: exclude src/worker/. The ✘/🚩 map markers there stamp baked Path2D shapes (see tools/generate-glyph-markers.ts) in a solid marker color, so a layered color font would never apply to them.
target: ["src/**/*.ts", "public/*.html", "tools/lib/prefab-html.ts"],
exclude: [`${path.sep}worker${path.sep}`],
dist: "NotoColorEmoji.subset.woff2",
Expand Down Expand Up @@ -95,8 +95,11 @@ async function collectFiles(job: FontJob): Promise<string[]> {
return [...files];
}

// Reads maxp.numGlyphs from a raw (non-woff2) sfnt buffer, to sanity-check
// that a subset job actually kept more than just the mandatory .notdef glyph.
/**
* Reads `maxp.numGlyphs` from a raw (non-woff2) sfnt buffer as a
* sanity check that the subset job kept more than just the mandatory
* `.notdef` glyph.
*/
function countGlyphs(sfnt: Uint8Array): number {
const view = new DataView(sfnt.buffer, sfnt.byteOffset, sfnt.byteLength);
const numTables = view.getUint16(4);
Expand Down
22 changes: 13 additions & 9 deletions tools/generate-glyph-markers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,29 +15,33 @@ import {
interface MarkerJob {
fontFile: string;
glyph: string;
// Hand-tuned placement of `glyph` within the shared VIEWPORT square.
/** Hand-tuned placement of `glyph` within the shared `VIEWPORT` square. */
x: number;
y: number;
// Point in the VIEWPORT square that render time should align with the
// target coordinate (e.g. the flag's pole base, not its bbox centre).
/**
* Point in the `VIEWPORT` square that render time should align with
* the target coordinate (e.g. the flag's pole base, not its bbox
* centre).
*/
anchor: { x: number; y: number };
}

// Baked at build time; each job writes public/{name}.svg and
// public/{name}-path.json for canvas/SVG use without runtime font loading.
/**
* Marker jobs baked at build time. Each job writes
* `public/{name}.svg` and `public/{name}-path.json` for canvas/SVG
* use without runtime font loading.
*/
const MARKERS: Readonly<Record<string, MarkerJob>> = {
"heavy-ballot-x": {
// U+2718 HEAVY BALLOT X, used for the Prefab Sign / Toggle Sign marker.
// WHY: U+2718 HEAVY BALLOT X drives the Prefab Sign / Toggle Sign marker.
fontFile: "NotoSansSymbols2-Regular.ttf",
glyph: "✘",
x: 52,
y: 180,
anchor: { x: 125.8, y: 117 },
},
"triangular-flag": {
// U+1F6A9 TRIANGULAR FLAG ON POST, used for the flag marker. Uses the
// "old" Noto Emoji release, which draws a filled flag; the current
// release's 🚩 is hollow and hard to see against map terrain.
// WHY: NotoEmojiOld draws a filled U+1F6A9 flag. The current release's 🚩 is hollow and hard to see against map terrain.
fontFile: "NotoEmojiOld-Regular.ttf",
glyph: "🚩",
x: -18,
Expand Down
3 changes: 1 addition & 2 deletions tools/generate-labels-jsons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ async function main() {
vanillaDir("Data", "Config", "Localization.csv"),
);

// Pre-group labels by their source file once, so per-language extraction
// does not re-scan the full label Map for every (lang, file) pair.
// WHY: per-language extraction would otherwise re-scan the full label Map for every (lang, file) pair.
const labelsByFile = buildArrayMapByEntries(
Array.from(labels, ([id, label]) => [label.file, [id, label] as const]),
);
Expand Down
1 change: 0 additions & 1 deletion tools/generate-prefab-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ async function copyJpg(xmlFileName: string) {
await fs.copyFile(jpg, dist);
} catch (e) {
if (isErrnoException(e) && e.code === "ENOENT") {
// No jpg
return;
}
throw e;
Expand Down
35 changes: 23 additions & 12 deletions tools/generate-prefab-properties-jsons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,11 @@ function extractDifficulties(prefabXmls: PrefabXmls) {
);
}

// PrefabSize "X, Y, Z" → [X (width), Z (depth), Y (height), yOffset]. Absent or
// zero footprint (sign/part) is skipped; a malformed present value throws.
/**
* Reshapes `PrefabSize` from `"X, Y, Z"` into `[X (width), Z (depth),
* Y (height), yOffset]`. Prefabs with an absent or zero footprint
* (signs, embedded parts) are skipped. A malformed present value throws.
*/
export function extractMeshSizes(prefabXmls: PrefabXmls) {
return Object.fromEntries(
Object.entries(prefabXmls)
Expand All @@ -116,7 +119,7 @@ export function extractMeshSizes(prefabXmls: PrefabXmls) {
number,
number,
];
// Zero/negative footprints (embedded parts) have no drawable box.
// WHY: zero or negative footprints belong to embedded parts and have no drawable box.
if (x <= 0 || z <= 0) return [];
const yOffset = parseYOffset(
findValueEntry(entries, "YOffset")?.value,
Expand All @@ -129,8 +132,11 @@ export function extractMeshSizes(prefabXmls: PrefabXmls) {
);
}

// YOffset is authored as an integer string; reject anything else so a schema
// change surfaces loudly instead of coercing to a wrong box depth.
/**
* Parses the `YOffset` property. It is authored as an integer string,
* so anything else is rejected. Silent coercion would push the drawable
* box to the wrong depth on a schema change.
*/
function parseYOffset(raw: string | undefined, prefabName: string): number {
if (raw === undefined) return 0;
if (!/^-?\d+$/.test(raw.trim())) {
Expand All @@ -139,10 +145,13 @@ function parseYOffset(raw: string | undefined, prefabName: string): number {
return parseInt(raw.trim(), 10);
}

// Mirrors PrefabData.Init: DensityScore = (TotalVertices + 50000) / 100000
// using C# integer division, so the result is always an int. Prefabs without
// a Stats block (or TotalVertices=0) get 0 and become "low density" which the
// renderer treats as ×0.4 brightness, matching the game preview.
/**
* Mirrors `PrefabData.Init`: `DensityScore = (TotalVertices + 50000) /
* 100000` using C# integer division, so the result is always an int.
* Prefabs without a `Stats` block (or `TotalVertices=0`) get 0 and
* become "low density", which the renderer treats as ×0.4 brightness
* to match the game preview.
*/
function extractDensityScores(prefabXmls: PrefabXmls) {
return Object.fromEntries(
Object.entries(prefabXmls)
Expand All @@ -168,9 +177,11 @@ function rgbFloatToHex([r, g, b]: [number, number, number]): string {
return `#${hex(r)}${hex(g)}${hex(b)}`;
}

// District preview_color lookup table consumed by the renderer at draw time.
// Districts without a preview_color are dropped so the renderer can fall back
// to the wilderness default.
/**
* Builds the district `preview_color` lookup table consumed by the
* renderer at draw time. Districts without a `preview_color` are
* dropped so the renderer can fall back to the wilderness default.
*/
function extractDistrictColors(districts: District[]) {
return Object.fromEntries(
districts
Expand Down
40 changes: 28 additions & 12 deletions tools/lib/ico.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
// Builds a multi-resolution .ico file from PNG-encoded images, using the
// ICONDIR/ICONDIRENTRY layout from the ICO format spec. Modern ICO readers
// (Windows Vista+, browsers) accept PNG-compressed entries directly, so no
// BMP conversion is needed.
const ICONDIR_BYTES = 6;
const ICONDIRENTRY_BYTES = 16;

Expand All @@ -10,6 +6,26 @@ export interface IcoImage {
png: Uint8Array;
}

/**
* Builds a multi-resolution `.ico` file from PNG-encoded images. Modern
* ICO readers (Windows Vista+, browsers) accept PNG-compressed entries
* directly, so no BMP conversion is needed.
*
* ICONDIR (6 bytes):
* 0 uint16 reserved (0)
* 2 uint16 type (1 = icon)
* 4 uint16 image count
*
* ICONDIRENTRY (16 bytes, one per image):
* 0 uint8 width (0 encodes 256)
* 1 uint8 height (0 encodes 256)
* 2 uint8 color count (0 = no palette)
* 3 uint8 reserved
* 4 uint16 color planes
* 6 uint16 bits per pixel
* 8 uint32 image size
* 12 uint32 image offset
*/
export function buildIco(images: readonly IcoImage[]): Uint8Array {
if (images.length === 0) {
throw new Error("buildIco: at least one image is required");
Expand All @@ -28,19 +44,19 @@ export function buildIco(images: readonly IcoImage[]): Uint8Array {
const out = new Uint8Array(totalSize);
const view = new DataView(out.buffer);

view.setUint16(0, 0, true); // reserved
view.setUint16(2, 1, true); // type: 1 = icon
view.setUint16(0, 0, true);
view.setUint16(2, 1, true);
view.setUint16(4, images.length, true);

let dataOffset = dirSize;
images.forEach(({ size, png }, i) => {
const entryOffset = ICONDIR_BYTES + ICONDIRENTRY_BYTES * i;
out[entryOffset] = size === 256 ? 0 : size; // width, 0 means 256
out[entryOffset + 1] = size === 256 ? 0 : size; // height, 0 means 256
out[entryOffset + 2] = 0; // color count: 0 = no palette
out[entryOffset + 3] = 0; // reserved
view.setUint16(entryOffset + 4, 1, true); // color planes
view.setUint16(entryOffset + 6, 32, true); // bits per pixel
out[entryOffset] = size === 256 ? 0 : size;
out[entryOffset + 1] = size === 256 ? 0 : size;
out[entryOffset + 2] = 0;
out[entryOffset + 3] = 0;
view.setUint16(entryOffset + 4, 1, true);
view.setUint16(entryOffset + 6, 32, true);
view.setUint32(entryOffset + 8, png.length, true);
view.setUint32(entryOffset + 12, dataOffset, true);

Expand Down
12 changes: 6 additions & 6 deletions tools/lib/prefab-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import * as path from "node:path";
import { expandGlob } from "@std/fs/expand-glob";
import { vanillaDir } from "./utils.ts";

// Known prefab sidecar suffixes. Order matters: longer suffixes (e.g.
// ".blocks.nim") must precede shorter ones (".nim") so stripping picks the
// most specific match.
// INVARIANT: longer suffixes must precede shorter ones so that stripping picks the most specific match (e.g. `.blocks.nim` before `.nim`).
const PREFAB_SUFFIXES = [".blocks.nim", ".tts", ".xml", ".jpg"] as const;

// Predicate for prefab XML files that should be ignored — currently the
// auto-generated `<name>_signs.xml` companion files. Consolidated here so that
// any future exclusions live in a single place.
/**
* Predicate for prefab XML files that should be ignored, currently the
* auto-generated `<name>_signs.xml` companion files. Consolidated so
* future exclusions live in one place.
*/
function isIgnoredPrefabXml(p: string): boolean {
return p.endsWith("_signs.xml");
}
Expand Down
Loading