From bcabad567e83ba0a2e6aaa4de0c4d453e6f4b7e0 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Fri, 6 Mar 2026 11:32:37 +0100 Subject: [PATCH 01/12] Distinguish simple and layered icons This allows the type to be automatically inferred and removes the need for type casts. --- packages/host/src/export.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/host/src/export.ts b/packages/host/src/export.ts index 4368c7e4..719e6e96 100644 --- a/packages/host/src/export.ts +++ b/packages/host/src/export.ts @@ -14,6 +14,7 @@ interface Prototype { name: string, } interface SimpleIconSpecification { + icons?: never; icon: string; icon_size?: number; icon_mipmaps?: number; @@ -261,9 +262,9 @@ function fixIcons(item: ItemPrototype) { /** Get the primary icon path from a prototype (first layer for layered, .icon for simple). */ function getPrimaryIconPath(item: ItemPrototype): string | undefined { if (item.icons) { - return (item as LayeredIconSpecification).icons[0]?.icon; + return item.icons[0]?.icon; } - return (item as SimpleIconSpecification).icon; + return item.icon; } const itemTypes = new Set([ @@ -394,7 +395,7 @@ async function exportItems( let iconPos: number | undefined; if (item.icons) { icon = await loadLayeredIcon( - server, modVersions, item as LayeredIconSpecification, SHEET_ICON_SIZE, state.iconCache, + server, modVersions, item, SHEET_ICON_SIZE, state.iconCache, ); iconPos = state.pos; @@ -402,7 +403,7 @@ async function exportItems( iconPos = state.simpleIcons.get(item.icon as string); if (iconPos === undefined) { icon = await loadSimpleIcon( - server, modVersions, item as SimpleIconSpecification, SHEET_ICON_SIZE, state.iconCache, + server, modVersions, item, SHEET_ICON_SIZE, state.iconCache, ); if (icon) { iconPos = state.pos; From 076fb933dc6d5c38292eb593c7908b9c3d1dd445 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Fri, 6 Mar 2026 13:56:53 +0100 Subject: [PATCH 02/12] Share structured data types in export Place shared types of the export functionality into lib and use in the web interface to make sure the data is not incorrectly handled. Resolves #526 --- packages/controller/src/routes.ts | 2 +- packages/host/src/export.ts | 23 ++-- packages/lib/browser.ts | 1 + packages/lib/index.ts | 1 + packages/lib/src/data/ModPack.ts | 7 +- packages/lib/src/export.ts | 119 +++++++++++++++++++++ packages/lib/tsconfig.browser.json | 1 + packages/web_ui/src/model/item_metadata.ts | 20 ++-- plugins/subspace_storage/web/index.tsx | 4 +- 9 files changed, 143 insertions(+), 35 deletions(-) create mode 100644 packages/lib/src/export.ts diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts index a47ceb2d..fc700b71 100644 --- a/packages/controller/src/routes.ts +++ b/packages/controller/src/routes.ts @@ -246,7 +246,7 @@ async function uploadExport(req: Request, res: Response) { ]; let assets: any = {}; - let settingPrototypes = {}; + let settingPrototypes: lib.ExportSettings = {}; for (let filePath of exportFiles) { let file = zip.file(filePath); if (!file) { diff --git a/packages/host/src/export.ts b/packages/host/src/export.ts index 719e6e96..7c12a04d 100644 --- a/packages/host/src/export.ts +++ b/packages/host/src/export.ts @@ -8,11 +8,6 @@ import * as lib from "@clusterio/lib"; import * as libBuildMod from "@clusterio/lib/dist/node/build_mod"; import type { FactorioServer } from "./server"; -interface Prototype { - [index: string]: unknown, - type: string, - name: string, -} interface SimpleIconSpecification { icons?: never; icon: string; @@ -33,11 +28,9 @@ interface LayeredIconSpecification { icon_mipmaps?: number; } type IconSpecification = LayeredIconSpecification | SimpleIconSpecification; -type ItemPrototype = Prototype & IconSpecification; +type ItemPrototype = lib.FactorioPrototypeBase & IconSpecification; -type Prototypes = Record> - /** * Generate the export mod needed for exportData * @@ -326,7 +319,7 @@ const entityTypes = new Set([ "agricultural-tower", "market", "lane-splitter", ]); -function filterPrototypes(prototypes: Prototypes, allowedTypes: Set): ItemPrototype[] { +function filterPrototypes(prototypes: lib.FactorioPrototypes, allowedTypes: Set): ItemPrototype[] { return Object.entries(prototypes) .filter(([type]) => allowedTypes.has(type)) .flatMap(([_, typePrototypes]) => Object.values(typePrototypes) as ItemPrototype[]) @@ -336,7 +329,7 @@ function filterPrototypes(prototypes: Prototypes, allowedTypes: Set): It /** Shared state for building a unified spritesheet across all categories. */ interface SheetState { sheet: Jimp; - metadata: Map; + metadata: Map; iconCache: IconCache; simpleIcons: Map; pos: number; @@ -514,12 +507,12 @@ async function exportLocale( export async function exportData(server: FactorioServer) { await generateExportMod(server); - let settings: Prototypes = {}; - let prototypes: Prototypes = {}; + let settings: lib.ExportSettings = {}; + let prototypes: lib.ExportPrototypes = {}; let modVersions = new Map(); let modOrder: string[] = []; - function add(obj: Prototypes, prototype: Prototype) { + function add(obj: lib.FactorioPrototypes, prototype: lib.FactorioPrototypeBase) { if (!Object.prototype.hasOwnProperty.call(obj, prototype.type)) { obj[prototype.type] = {}; } @@ -598,7 +591,7 @@ export async function exportData(server: FactorioServer) { let zip = new JSZip(); zip.file("export/settings.json", JSON.stringify(settings)); zip.file("export/prototypes.json", JSON.stringify(prototypes)); - zip.file("export/locale.json", JSON.stringify([...locale.entries()])); + zip.file("export/locale.json", JSON.stringify([...locale.entries()] satisfies lib.ExportLocale)); // Build a single unified spritesheet across all prototype categories. const state = await createSheetState(); @@ -616,7 +609,7 @@ export async function exportData(server: FactorioServer) { const usedRows = Math.ceil(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)); state.sheet.crop(0, 0, SHEET_WIDTH, Math.max(usedRows * SHEET_ICON_SIZE, 1)); zip.file("export/spritesheet.png", await state.sheet.getBufferAsync(Jimp.MIME_PNG)); - zip.file("export/metadata.json", JSON.stringify([...state.metadata.entries()])); + zip.file("export/metadata.json", JSON.stringify([...state.metadata.entries()] satisfies lib.ExportMetadata)); server._logger.info(`Export complete: ${state.metadata.size} icons on ${usedRows} row(s)`); diff --git a/packages/lib/browser.ts b/packages/lib/browser.ts index 66b3d5c1..aa0273ea 100644 --- a/packages/lib/browser.ts +++ b/packages/lib/browser.ts @@ -2,6 +2,7 @@ export * from "./src/api"; export * from "./src/config"; export * from "./src/data"; export * from "./src/errors"; +export * from "./src/export"; export * from "./src/factorio/exchange_string"; export * from "./src/helpers"; export * from "./src/ini"; diff --git a/packages/lib/index.ts b/packages/lib/index.ts index d8240ffe..3319cc89 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -9,6 +9,7 @@ export * from "./src/config"; export * from "./src/data"; export * from "./src/database"; export * from "./src/errors"; +export * from "./src/export"; export * from "./src/external"; export * from "./src/factorio"; export * from "./src/file_ops"; diff --git a/packages/lib/src/data/ModPack.ts b/packages/lib/src/data/ModPack.ts index df3adf7d..29daf36d 100644 --- a/packages/lib/src/data/ModPack.ts +++ b/packages/lib/src/data/ModPack.ts @@ -5,6 +5,7 @@ import * as libSchema from "../schema"; import * as libLuaTools from "../lua_tools"; import ExportManifest from "./ExportManifest"; +import type { ExportSettings } from "../export"; import type { Logger } from "../logging"; import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo"; @@ -357,7 +358,7 @@ export default class ModPack { * Setting prototypes exported from the game. * @param logger - Logger used to report warnings on. */ - fillDefaultSettings(settingPrototypes: Record, logger: Logger) { + fillDefaultSettings(settingPrototypes: ExportSettings, logger: Logger) { const knownTypes = ["bool-setting", "int-setting", "double-setting", "string-setting", "color-setting"]; let prototypes = Object.entries(settingPrototypes) .filter(([type, _]) => knownTypes.includes(type)) @@ -370,7 +371,7 @@ export default class ModPack { logger.warn(`Ignoring ${prototype.name} with unknown setting_type ${settingType}`); continue; } - if (prototype.default_value === undefined) { + if ((prototype as any).default_value === undefined) { logger.warn(`Ignoring ${prototype.name} with missing default_value`); continue; } @@ -389,7 +390,7 @@ export default class ModPack { if (type === "color-setting") { value = libLuaTools.normalizeColor(value as object); } - this.settings[settingType].set(prototype.name, { value }); + this.settings[settingType].set(prototype.name, { value } as ModSetting); } } diff --git a/packages/lib/src/export.ts b/packages/lib/src/export.ts new file mode 100644 index 00000000..71f3a18b --- /dev/null +++ b/packages/lib/src/export.ts @@ -0,0 +1,119 @@ +import { FactorioColor } from "./lua_tools"; + +/** + * A localised string. + * @see [LocalisedString](https://lua-api.factorio.com/stable/types/LocalisedString.html) + */ +export type FactorioLocalisedString = string | [string, ...FactorioLocalisedString[]]; + +/** + * Shared properties of all prototypes. + * @see [PrototypeBase](https://lua-api.factorio.com/stable/types/PrototypeBase.html) + */ +export interface FactorioPrototypeBase { + [index: string]: unknown, + type: string, + name: string, + order?: string, + localised_name?: FactorioLocalisedString, + localised_description?: FactorioLocalisedString, + factoriopedia_description?: FactorioLocalisedString, + subgroup?: string, + hidden?: boolean, + hidden_in_factoriopedia?: boolean, + parameter?: boolean, + factoriopedia_simulation?: unknown, +} + +/** + * Stage the mod setting is part of. + * @see [settings_type property](https://wiki.factorio.com/Tutorial:Mod_settings#The_setting_type_property) + */ +export type FactorioModSettingType = "startup" | "runtime-global" | "runtime-per-user"; + +/** + * Boolean mod setting prototype. + * @see [bool-setting](https://wiki.factorio.com/Tutorial:Mod_settings#bool-setting) + */ +export interface FactorioBoolSettingPrototype extends FactorioPrototypeBase{ + type: "bool-setting", + setting_type?: FactorioModSettingType, + default_value: boolean, + forced_value?: boolean, +} + +/** + * Integer mod setting prototype. + * @see [int-setting](https://wiki.factorio.com/Tutorial:Mod_settings#int-setting) + */ +export interface FactorioIntSettingPrototype extends FactorioPrototypeBase{ + type: "int-setting", + setting_type?: FactorioModSettingType, + default_value: number, + minimum_value?: number, + maximum_value?: number, + allowed_values?: number[], +} + +/** + * Real number mod setting prototype. + * @see [double-setting](https://wiki.factorio.com/Tutorial:Mod_settings#double-setting) + */ +export interface FactorioDoubleSettingPrototype extends FactorioPrototypeBase{ + type: "double-setting", + setting_type?: FactorioModSettingType, + default_value: number, + minimum_value?: number, + maximum_value?: number, + allowed_values?: number[], +} + +/** + * String mod setting prototype. + * @see [string-setting](https://wiki.factorio.com/Tutorial:Mod_settings#string-setting) + */ +export interface FactorioStringSettingPrototype extends FactorioPrototypeBase{ + type: "string-setting", + setting_type?: FactorioModSettingType, + default_value: string, + allow_black?: boolean, + auto_trim?: string, + allowed_values?: string[], +} + +/** + * Color mod setting prototype. + * @see [color-setting](https://wiki.factorio.com/Tutorial:Mod_settings#color-setting) + */ +export interface FactorioColorSettingPrototype extends FactorioPrototypeBase{ + type: "color-setting", + setting_type?: FactorioModSettingType, + default_value: FactorioColor, + forced_value?: boolean, +} + +export type FactorioModSettingPrototype = + | FactorioBoolSettingPrototype + | FactorioIntSettingPrototype + | FactorioDoubleSettingPrototype + | FactorioStringSettingPrototype + | FactorioColorSettingPrototype +; + +/** + * A list of prototypes as it appears in Factorio's Lua API. + * @see [Data.raw](https://lua-api.factorio.com/stable/types/Data.html#raw) + */ +export type FactorioPrototypes = Record> + +export type ExportSettings = FactorioPrototypes; +export type ExportPrototypes = FactorioPrototypes; +export type ExportLocale = [string, string][]; +export interface ExportMetadataEntry { + x: number, + y: number, + size: number, + category: string, + path?: string, +} +export type ExportMetadata = [string, ExportMetadataEntry][]; diff --git a/packages/lib/tsconfig.browser.json b/packages/lib/tsconfig.browser.json index 97bcd427..8a87bca4 100644 --- a/packages/lib/tsconfig.browser.json +++ b/packages/lib/tsconfig.browser.json @@ -10,6 +10,7 @@ "./src/check_singleton_import.ts", "./src/datastore.ts", "./src/errors.ts", + "./src/export.ts", "./src/factorio/exchange_string.ts", "./src/helpers.ts", "./src/hash.ts", diff --git a/packages/web_ui/src/model/item_metadata.ts b/packages/web_ui/src/model/item_metadata.ts index a170566e..4235546d 100644 --- a/packages/web_ui/src/model/item_metadata.ts +++ b/packages/web_ui/src/model/item_metadata.ts @@ -1,18 +1,10 @@ import { useEffect, useState } from "react"; import { useExportManifest } from "./export_manifest"; +import { ExportMetadataEntry } from "@clusterio/lib"; -type Metadata = { - x: number; - y: number; - size: number; - category: string; - localised_name?: any[]; - path?: string; -}; - -const metadataCaches = new Map>(); +const metadataCaches = new Map>(); let cssInjected = false; /** @@ -20,9 +12,9 @@ let cssInjected = false; * CSS class name: `.{category}-{CSS.escape(name)}` * e.g. `.item-iron-plate`, `.recipe-iron-plate`, `.signal-signal-red`, `.planet-nauvis` */ -function useSpriteMetadata(category: string): Map { +function useSpriteMetadata(category: string): Map { const exportManifest = useExportManifest(); - const [metadata, setMetadata] = useState>( + const [metadata, setMetadata] = useState>( metadataCaches.get(category) ?? new Map() ); @@ -41,7 +33,7 @@ function useSpriteMetadata(category: string): Map { return; } - const data: [string, Metadata][] = await response.json(); + const data: [string, ExportMetadataEntry][] = await response.json(); if (!cssInjected) { const sheetUrl = `${staticRoot}static/${exportManifest.assets["spritesheet"]}`; @@ -62,7 +54,7 @@ function useSpriteMetadata(category: string): Map { cssInjected = true; } - const cache = new Map(); + const cache = new Map(); for (const [key, meta] of data) { if (meta.category === category) { const name = key.includes(".") ? key.slice(key.indexOf(".") + 1) : key; diff --git a/plugins/subspace_storage/web/index.tsx b/plugins/subspace_storage/web/index.tsx index 0ad1941f..0ddb3b56 100644 --- a/plugins/subspace_storage/web/index.tsx +++ b/plugins/subspace_storage/web/index.tsx @@ -41,9 +41,9 @@ function StoragePage() { function getLocaleName(itemName: string) { let localeName = itemName; let meta = itemMetadata.get(itemName); - if (meta && meta.localised_name) { + if (meta && (meta as any).localised_name) { // TODO: implement the locale to name conversion. - localeName = locale.get(meta.localised_name[0])!; + localeName = locale.get((meta as any).localised_name[0])!; } else { for (let section of ["item-name", "entity-name", "fluid-name", "equipment-name"]) { let name = locale.get(`${section}.${itemName}`); From 4c01f6a789fa8589f145d0a2c84558d2dd97a8ab Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Fri, 6 Mar 2026 14:39:20 +0100 Subject: [PATCH 03/12] Fix export missing localised name The change in #838 removed localised names from the exported metadata. Add it back again so that items with overriden names are translated. --- packages/host/src/export.ts | 1 + packages/lib/src/export.ts | 1 + plugins/subspace_storage/web/index.tsx | 8 ++++++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/host/src/export.ts b/packages/host/src/export.ts index 7c12a04d..dadd34b6 100644 --- a/packages/host/src/export.ts +++ b/packages/host/src/export.ts @@ -411,6 +411,7 @@ async function exportItems( x: iconPos * SHEET_ICON_SIZE % SHEET_WIDTH, y: Math.floor(iconPos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE, size: SHEET_ICON_SIZE, + localised_name: item.localised_name, category, ...(iconPath ? { path: iconPath } : {}), }); diff --git a/packages/lib/src/export.ts b/packages/lib/src/export.ts index 71f3a18b..d461471a 100644 --- a/packages/lib/src/export.ts +++ b/packages/lib/src/export.ts @@ -113,6 +113,7 @@ export interface ExportMetadataEntry { x: number, y: number, size: number, + localised_name?: FactorioLocalisedString, category: string, path?: string, } diff --git a/plugins/subspace_storage/web/index.tsx b/plugins/subspace_storage/web/index.tsx index 0ddb3b56..9c81fb02 100644 --- a/plugins/subspace_storage/web/index.tsx +++ b/plugins/subspace_storage/web/index.tsx @@ -41,9 +41,13 @@ function StoragePage() { function getLocaleName(itemName: string) { let localeName = itemName; let meta = itemMetadata.get(itemName); - if (meta && (meta as any).localised_name) { + if (meta && meta.localised_name) { // TODO: implement the locale to name conversion. - localeName = locale.get((meta as any).localised_name[0])!; + if (typeof meta.localised_name === "string") { + localeName = meta.localised_name; + } else { + localeName = locale.get(meta.localised_name[0])!; + } } else { for (let section of ["item-name", "entity-name", "fluid-name", "equipment-name"]) { let name = locale.get(`${section}.${itemName}`); From f4916594b869ed12e54c6e94d6c8e3a2c0e4fbe9 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Fri, 6 Mar 2026 16:57:34 +0100 Subject: [PATCH 04/12] Update jimp to version 1.6.0 Resolves #854 --- packages/host/package.json | 12 ++++++------ packages/host/src/export.ts | 27 ++++++++++++++------------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/host/package.json b/packages/host/package.json index ee8bbfd6..bae9248f 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -23,26 +23,26 @@ "dependencies": { "@clusterio/lib": "workspace:*", "@sinclair/typebox": "catalog:", - "set-blocking": "catalog:", "fs-extra": "catalog:", + "jimp": "^1.6.0", "jszip": "catalog:", - "winston": "catalog:", - "winston-daily-rotate-file": "catalog:", - "jimp": "^0.22.8", "pidusage": "^3.0.2", "rcon-client": "^4.2.5", "semver": "^7.7.1", + "set-blocking": "catalog:", + "winston": "catalog:", + "winston-daily-rotate-file": "catalog:", "yargs": "^17.7.2" }, "publishConfig": { "access": "public" }, "devDependencies": { - "@types/node": "catalog:", "@types/fs-extra": "catalog:", - "@types/set-blocking": "catalog:", + "@types/node": "catalog:", "@types/pidusage": "^2.0.2", "@types/semver": "^7.5.0", + "@types/set-blocking": "catalog:", "@types/yargs": "^17.0.33", "typescript": "catalog:" } diff --git a/packages/host/src/export.ts b/packages/host/src/export.ts index dadd34b6..4a76a4ab 100644 --- a/packages/host/src/export.ts +++ b/packages/host/src/export.ts @@ -1,7 +1,7 @@ // Export of item icons and locale import fs from "fs-extra"; import path from "path"; -import Jimp from "jimp"; +import { Jimp, JimpMime, type JimpInstance } from "jimp"; import JSZip from "jszip"; import * as lib from "@clusterio/lib"; @@ -63,7 +63,7 @@ async function generateExportMod(server: FactorioServer) { }); } -let zipCache = new Map(); +let zipCache = new Map(); async function loadZip(server: FactorioServer, modVersions: Map, mod: string) { let modVersion = modVersions.get(mod); if (!modVersion) { @@ -77,7 +77,7 @@ async function loadZip(server: FactorioServer, modVersions: Map, zipCache.set(zipPath, zip); } - return zip.folder(lib.findRoot(zip)); + return zip.folder(lib.findRoot(zip))!; } /** @@ -128,7 +128,8 @@ async function loadFile(server: FactorioServer, modVersions: Map return await file.async("nodebuffer"); } -type IconCache = Map; +type Image = JimpInstance; +type IconCache = Map; async function loadIcon( server: FactorioServer, modVersions: Map, @@ -141,8 +142,8 @@ async function loadIcon( if (icon === undefined) { let fileContent = await loadFile(server, modVersions, iconPath); if (fileContent) { - icon = await Jimp.read(fileContent); - icon.crop(0, 0, iconSize, iconSize); + icon = await Jimp.fromBuffer(fileContent) as Image; + icon.crop({ x: 0, y: 0, w: iconSize, h: iconSize }); iconCache.set(iconPath, icon); } else { icon = null; @@ -178,7 +179,7 @@ async function loadLayeredIcon( iconCache: IconCache, ) { let baseLayerSize = (item.icons[0].icon_size || item.icon_size) ?? 64; - let icon = await Jimp.create(size, size); + let icon = new Jimp({ width: size, height: size }); // The scaling factor of the base layer let baseLayerScale = item.icons[0].scale || 32 / baseLayerSize; @@ -328,7 +329,7 @@ function filterPrototypes(prototypes: lib.FactorioPrototypes, allowedTypes: Set< /** Shared state for building a unified spritesheet across all categories. */ interface SheetState { - sheet: Jimp; + sheet: Image; metadata: Map; iconCache: IconCache; simpleIcons: Map; @@ -340,7 +341,7 @@ const SHEET_WIDTH = 1024; async function createSheetState(): Promise { return { - sheet: await Jimp.create(SHEET_WIDTH, SHEET_ICON_SIZE), + sheet: new Jimp({ width: SHEET_WIDTH, height: SHEET_ICON_SIZE }), metadata: new Map(), iconCache: new Map(), simpleIcons: new Map(), @@ -353,7 +354,7 @@ async function growSheet(state: SheetState) { const row = Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)); const neededHeight = (row + 1) * SHEET_ICON_SIZE; if (neededHeight > state.sheet.bitmap.height) { - const extended = await Jimp.create(SHEET_WIDTH, neededHeight); + const extended = new Jimp({ width: SHEET_WIDTH, height: neededHeight }); extended.composite(state.sheet, 0, 0); (state.sheet as any).bitmap = (extended as any).bitmap; } @@ -384,7 +385,7 @@ async function exportItems( continue; } - let icon: Jimp | null = null; + let icon: Image | null = null; let iconPos: number | undefined; if (item.icons) { icon = await loadLayeredIcon( @@ -608,8 +609,8 @@ export async function exportData(server: FactorioServer) { // Crop sheet to actual used height and write single spritesheet + metadata. const usedRows = Math.ceil(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)); - state.sheet.crop(0, 0, SHEET_WIDTH, Math.max(usedRows * SHEET_ICON_SIZE, 1)); - zip.file("export/spritesheet.png", await state.sheet.getBufferAsync(Jimp.MIME_PNG)); + state.sheet.crop({ x: 0, y: 0, w: SHEET_WIDTH, h: Math.max(usedRows * SHEET_ICON_SIZE, 1) }); + zip.file("export/spritesheet.png", await state.sheet.getBuffer(JimpMime.png)); zip.file("export/metadata.json", JSON.stringify([...state.metadata.entries()] satisfies lib.ExportMetadata)); server._logger.info(`Export complete: ${state.metadata.size} icons on ${usedRows} row(s)`); From 3b8058de8113dc52e7238f9b7bab0414a2e55b37 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Thu, 12 Mar 2026 18:26:04 +0100 Subject: [PATCH 05/12] Refactor icon export to be based on prototypes Use the defines.prototypes data structure from the game to find which prototype sub-types belong in the same base type, and export icons based on these base type. This renames the planets category to space-location, signal to virtual-signal and plits item up into item and fluid. And changes the exported metadata to include prototypes with no icons, as future code might want to use it to for example enumerate all item prototypes. --- packages/controller/src/routes.ts | 1 + packages/host/lua/export/control.lua | 1 + packages/host/src/export.ts | 259 +++++++-------- packages/host/src/export_fallback.ts | 294 ++++++++++++++++++ packages/lib/src/export.ts | 21 +- .../src/components/IconReferencePage.tsx | 34 +- packages/web_ui/src/model/item_metadata.ts | 37 ++- plugins/subspace_storage/web/index.tsx | 20 +- 8 files changed, 482 insertions(+), 185 deletions(-) create mode 100644 packages/host/src/export_fallback.ts diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts index fc700b71..8c461c9d 100644 --- a/packages/controller/src/routes.ts +++ b/packages/controller/src/routes.ts @@ -241,6 +241,7 @@ async function uploadExport(req: Request, res: Response) { "export/settings.json", "export/prototypes.json", "export/locale.json", + "export/defines.json", "export/spritesheet.png", "export/metadata.json", ]; diff --git a/packages/host/lua/export/control.lua b/packages/host/lua/export/control.lua index 1833a4cc..ae895a8c 100644 --- a/packages/host/lua/export/control.lua +++ b/packages/host/lua/export/control.lua @@ -9,4 +9,5 @@ script.on_init(function() for name, mod_setting in pairs(mod_settings) do send_json("mod_setting_mod", { name = name, mod = mod_setting.mod }) end + send_json("defines", defines) end) diff --git a/packages/host/src/export.ts b/packages/host/src/export.ts index 4a76a4ab..0a401b42 100644 --- a/packages/host/src/export.ts +++ b/packages/host/src/export.ts @@ -7,6 +7,7 @@ import JSZip from "jszip"; import * as lib from "@clusterio/lib"; import * as libBuildMod from "@clusterio/lib/dist/node/build_mod"; import type { FactorioServer } from "./server"; +import { definesPrototypesFallback } from "./export_fallback"; interface SimpleIconSpecification { icons?: never; @@ -28,7 +29,7 @@ interface LayeredIconSpecification { icon_mipmaps?: number; } type IconSpecification = LayeredIconSpecification | SimpleIconSpecification; -type ItemPrototype = lib.FactorioPrototypeBase & IconSpecification; +type PrototypeWithIcon = lib.FactorioPrototypeBase & IconSpecification; /** @@ -241,7 +242,7 @@ async function loadLayeredIcon( return icon; } -function fixIcons(item: ItemPrototype) { +function fixIcons(item: PrototypeWithIcon) { const icons = item.icons; if (typeof icons === "object" && !(icons instanceof Array) && icons !== null) { // It's possible to specify icons as an object with arbitrary keys @@ -253,84 +254,28 @@ function fixIcons(item: ItemPrototype) { return item; } -/** Get the primary icon path from a prototype (first layer for layered, .icon for simple). */ -function getPrimaryIconPath(item: ItemPrototype): string | undefined { - if (item.icons) { - return item.icons[0]?.icon; - } - return item.icon; -} - -const itemTypes = new Set([ +const prototypesWithIcon = [ "item", - "ammo", - "capsule", - "gun", - "item-with-entity-data", - "item-with-label", - "item-with-inventory", - "blueprint-book", - "item-with-tags", - "selection-tool", - "blueprint", - "copy-paste-tool", - "deconstruction-item", - "upgrade-item", - "module", - "rail-planner", - "spidertron-remote", - "tool", - "armor", - "mining-tool", - "repair-tool", - // XXX Bad hack to get icons for fluids. These should be treated as a - // separate namespace from items as they may be named the same as items. "fluid", -]); + "recipe", + "virtual-signal", + "technology", + "space-location", + "quality", + "entity", +] as const; -const recipeTypes = new Set(["recipe"]); -const signalTypes = new Set(["virtual-signal"]); -const technologyTypes = new Set(["technology"]); -const planetTypes = new Set(["planet", "space-location"]); -const qualityTypes = new Set(["quality"]); - -// Entity types to export. Entities whose name matches an already-exported -const entityTypes = new Set([ - "assembling-machine", "furnace", "inserter", "transport-belt", "underground-belt", - "splitter", "loader", "loader-1x1", "mining-drill", "container", "logistic-container", - "electric-pole", "pipe", "pipe-to-ground", "pump", "storage-tank", "boiler", - "generator", "solar-panel", "accumulator", "reactor", "heat-pipe", "lab", "beacon", - "roboport", "lamp", "radar", "wall", "gate", "land-mine", "turret", "ammo-turret", - "electric-turret", "fluid-turret", "artillery-turret", "rocket-silo", "car", - "spider-vehicle", "locomotive", "cargo-wagon", "fluid-wagon", "artillery-wagon", - "train-stop", "rail-signal", "rail-chain-signal", "straight-rail", "curved-rail-a", - "curved-rail-b", "half-diagonal-rail", "elevated-straight-rail", "elevated-curved-rail-a", - "elevated-curved-rail-b", "elevated-half-diagonal-rail", "rail-ramp", "rail-support", - "space-platform-hub", "cargo-bay", "asteroid-collector", "thruster", "cargo-pod", - "cargo-landing-pad", "asteroid", - "construction-robot", "logistic-robot", "combat-robot", "capture-robot", - "unit", "unit-spawner", "spider-unit", "segmented-unit", - "tree", "plant", "fish", "resource", "cliff", "simple-entity", - "simple-entity-with-owner", "simple-entity-with-force", - "arithmetic-combinator", "decider-combinator", "constant-combinator", - "selector-combinator", "programmable-speaker", "power-switch", "display-panel", - "character", - "offshore-pump", "valve", "lightning-attractor", - "fusion-generator", "fusion-reactor", "burner-generator", - "agricultural-tower", "market", "lane-splitter", -]); - -function filterPrototypes(prototypes: lib.FactorioPrototypes, allowedTypes: Set): ItemPrototype[] { +function filterAndFixPrototypesWithIcons(prototypes: lib.FactorioPrototypes, allowedTypes: Set) { return Object.entries(prototypes) .filter(([type]) => allowedTypes.has(type)) - .flatMap(([_, typePrototypes]) => Object.values(typePrototypes) as ItemPrototype[]) + .flatMap(([_, typePrototypes]) => Object.values(typePrototypes) as PrototypeWithIcon[]) .map(fixIcons); } /** Shared state for building a unified spritesheet across all categories. */ interface SheetState { sheet: Image; - metadata: Map; + metadata: Record; iconCache: IconCache; simpleIcons: Map; pos: number; @@ -342,7 +287,7 @@ const SHEET_WIDTH = 1024; async function createSheetState(): Promise { return { sheet: new Jimp({ width: SHEET_WIDTH, height: SHEET_ICON_SIZE }), - metadata: new Map(), + metadata: Object.create(null), iconCache: new Map(), simpleIcons: new Map(), pos: 0, @@ -360,74 +305,92 @@ async function growSheet(state: SheetState) { } } +async function exportIcon( + server: FactorioServer, + modVersions: Map, + state: SheetState, + prototype: PrototypeWithIcon, +) { + let icon: Image | null = null; + let iconPos: number | undefined; + let iconData: lib.ExportMetadataEntry["icon"]; + let iconPath: string | undefined; + + if (prototype.icons) { + icon = await loadLayeredIcon( + server, modVersions, prototype, SHEET_ICON_SIZE, state.iconCache, + ); + iconPos = state.pos; + iconPath = prototype.icons[0].icon; + + } else if (prototype.icon) { + iconPos = state.simpleIcons.get(prototype.icon); + iconPath = prototype.icon; + if (iconPos === undefined) { + icon = await loadSimpleIcon( + server, modVersions, prototype, SHEET_ICON_SIZE, state.iconCache, + ); + if (icon) { + iconPos = state.pos; + state.simpleIcons.set(prototype.icon, state.pos); + } + } + } + + if (iconPos !== undefined) { + iconData = { + x: iconPos * SHEET_ICON_SIZE % SHEET_WIDTH, + y: Math.floor(iconPos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE, + size: SHEET_ICON_SIZE, + path: iconPath, + }; + } + + if (icon) { + await growSheet(state); + state.sheet.composite( + icon, + state.pos * SHEET_ICON_SIZE % SHEET_WIDTH, + Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE, + ); + state.pos += 1; + } + + return iconData; +} + /** - * Export item icons for a single category onto the shared spritesheet. + * Export prototype icons for a single base prototype onto the shared spritesheet. * * @param server - The server to load icons from. * @param modVersions - Mapping of mod name to version. - * @param items - Array of item prototypes to export. - * @param category - Category name for metadata. + * @param prototypes - Array of prototypes with icon to export for this base type. + * @param baseName - name of base type (used in metadata). * @param state - Shared spritesheet state (mutated in place). - * @returns Number of icons exported for this category. + * @returns Number of icons exported for this base type. * @internal */ -async function exportItems( +async function exportIcons( server: FactorioServer, modVersions: Map, - items: ItemPrototype[], - category: string, + prototypes: PrototypeWithIcon[], + baseName: string, state: SheetState, ) { + const metadata = state.metadata[baseName] ?? []; + state.metadata[baseName] ??= metadata; let count = 0; - for (let item of items) { - // Skip prototypes with no icon data at all - if (!item.icons && !item.icon) { - continue; - } - - let icon: Image | null = null; - let iconPos: number | undefined; - if (item.icons) { - icon = await loadLayeredIcon( - server, modVersions, item, SHEET_ICON_SIZE, state.iconCache, - ); - iconPos = state.pos; - - } else { - iconPos = state.simpleIcons.get(item.icon as string); - if (iconPos === undefined) { - icon = await loadSimpleIcon( - server, modVersions, item, SHEET_ICON_SIZE, state.iconCache, - ); - if (icon) { - iconPos = state.pos; - state.simpleIcons.set(item.icon as string, state.pos); - } - } - } - - if (iconPos !== undefined) { - const iconPath = getPrimaryIconPath(item); - state.metadata.set(`${category}.${item.name}`, { - x: iconPos * SHEET_ICON_SIZE % SHEET_WIDTH, - y: Math.floor(iconPos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE, - size: SHEET_ICON_SIZE, - localised_name: item.localised_name, - category, - ...(iconPath ? { path: iconPath } : {}), - }); - } - + for (let prototype of prototypes) { + const icon = await exportIcon(server, modVersions, state, prototype); if (icon) { - await growSheet(state); - state.sheet.composite( - icon, - state.pos * SHEET_ICON_SIZE % SHEET_WIDTH, - Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE, - ); - state.pos += 1; count += 1; } + metadata.push({ + name: prototype.name, + type: prototype.type, + localised_name: prototype.localised_name, + icon, + }); } return count; @@ -501,7 +464,7 @@ async function exportLocale( } /** - * Export the locale and item icons for the given factorio server + * Export locale, icons and metadata for the given factorio server * * @param server - The server to export the data from. * @returns zip file with exported data. @@ -513,6 +476,7 @@ export async function exportData(server: FactorioServer) { let prototypes: lib.ExportPrototypes = {}; let modVersions = new Map(); let modOrder: string[] = []; + let defines: lib.FactorioDefines | undefined; function add(obj: lib.FactorioPrototypes, prototype: lib.FactorioPrototypeBase) { if (!Object.prototype.hasOwnProperty.call(obj, prototype.type)) { @@ -533,6 +497,7 @@ export async function exportData(server: FactorioServer) { server._logger.error(`Unable to find ${name} in settings prototypes`); }); server.on("ipc-mod_list", data => { modVersions = new Map(Object.entries(data)); }); + server.on("ipc-defines", data => { defines = data; }); server.on("output", parsed => { if (parsed.format === "seconds" && parsed.type === "generic") { let match = /^Checksum of (.*): \d+$/.exec(parsed.message); @@ -553,6 +518,15 @@ export async function exportData(server: FactorioServer) { throw new Error("No prototypes got exported"); } + if (!defines) { + throw new Error("No defines got exported"); + } + + // compat with versions prior to 1.1.31 + if (!defines.prototypes) { + defines.prototypes = definesPrototypesFallback; + } + // Some mod authors put leading zeros into the versions of their zip files. let splitter = /^(.*)_(\d+)\.(\d+)\.(\d+)\.zip?$/; for (let entry of await fs.readdir(server.writePath("mods"))) { @@ -575,45 +549,40 @@ export async function exportData(server: FactorioServer) { } } - const categories: { name: string, types: Set }[] = [ - { name: "item", types: itemTypes }, - { name: "recipe", types: recipeTypes }, - { name: "signal", types: signalTypes }, - { name: "technology", types: technologyTypes }, - { name: "planet", types: planetTypes }, - { name: "quality", types: qualityTypes }, - { name: "entity", types: entityTypes }, - ]; - let locale = await exportLocale(server, modVersions, modOrder, "en"); // Free up the memory used by zip files loaded during the export. zipCache.clear(); let zip = new JSZip(); - zip.file("export/settings.json", JSON.stringify(settings)); - zip.file("export/prototypes.json", JSON.stringify(prototypes)); + zip.file("export/settings.json", JSON.stringify(settings satisfies lib.ExportSettings)); + zip.file("export/prototypes.json", JSON.stringify(prototypes satisfies lib.ExportPrototypes)); zip.file("export/locale.json", JSON.stringify([...locale.entries()] satisfies lib.ExportLocale)); + zip.file("export/defines.json", JSON.stringify(defines satisfies lib.ExportDefines)); - // Build a single unified spritesheet across all prototype categories. + // Build a single unified spritesheet across all prototypes with icons. const state = await createSheetState(); - for (const category of categories) { - const categoryItems = filterPrototypes(prototypes, category.types); - - if (categoryItems.length === 0) { + for (const baseName of prototypesWithIcon) { + if (!Object.prototype.hasOwnProperty.call(defines.prototypes, baseName)) { continue; } - const count = await exportItems(server, modVersions, categoryItems, category.name, state); - server._logger.info(`Exported ${count} ${category.name} icons`); + const subTypeNames = new Set(Object.keys(defines.prototypes[baseName])); + const prototypesWithIcons = filterAndFixPrototypesWithIcons(prototypes, subTypeNames); + + if (prototypesWithIcons.length === 0) { + continue; + } + const count = await exportIcons(server, modVersions, prototypesWithIcons, baseName, state); + server._logger.info(`Exported ${count} ${baseName} icons`); } // Crop sheet to actual used height and write single spritesheet + metadata. const usedRows = Math.ceil(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)); state.sheet.crop({ x: 0, y: 0, w: SHEET_WIDTH, h: Math.max(usedRows * SHEET_ICON_SIZE, 1) }); zip.file("export/spritesheet.png", await state.sheet.getBuffer(JimpMime.png)); - zip.file("export/metadata.json", JSON.stringify([...state.metadata.entries()] satisfies lib.ExportMetadata)); + zip.file("export/metadata.json", JSON.stringify(state.metadata satisfies lib.ExportMetadata)); - server._logger.info(`Export complete: ${state.metadata.size} icons on ${usedRows} row(s)`); + server._logger.info(`Export complete: ${state.pos} icons on ${usedRows} row(s)`); return zip; } diff --git a/packages/host/src/export_fallback.ts b/packages/host/src/export_fallback.ts new file mode 100644 index 00000000..75b03163 --- /dev/null +++ b/packages/host/src/export_fallback.ts @@ -0,0 +1,294 @@ +// defines.prototypes from Factorio 1.1.31, the first version that has this. +export const definesPrototypesFallback = { + "achievement": { + "achievement": 0, + "build-entity-achievement": 0, + "combat-robot-count": 0, + "construct-with-robots-achievement": 0, + "deconstruct-with-robots-achievement": 0, + "deliver-by-robots-achievement": 0, + "dont-build-entity-achievement": 0, + "dont-craft-manually-achievement": 0, + "dont-use-entity-in-energy-production-achievement": 0, + "finish-the-game-achievement": 0, + "group-attack-achievement": 0, + "kill-achievement": 0, + "player-damaged-achievement": 0, + "produce-achievement": 0, + "produce-per-hour-achievement": 0, + "research-achievement": 0, + "train-path-achievement": 0, + }, + "ambient-sound": { + "ambient-sound": 0, + }, + "ammo-category": { + "ammo-category": 0, + }, + "animation": { + "animation": 0, + }, + "autoplace-control": { + "autoplace-control": 0, + }, + "custom-input": { + "custom-input": 0, + }, + "damage-type": { + "damage-type": 0, + }, + "decorative": { + "optimized-decorative": 0, + }, + "editor-controller": { + "editor-controller": 0, + }, + "entity": { + "accumulator": 0, + "ammo-turret": 0, + "arithmetic-combinator": 0, + "arrow": 0, + "artillery-flare": 0, + "artillery-projectile": 0, + "artillery-turret": 0, + "artillery-wagon": 0, + "assembling-machine": 0, + "beacon": 0, + "beam": 0, + "boiler": 0, + "burner-generator": 0, + "car": 0, + "cargo-wagon": 0, + "character": 0, + "character-corpse": 0, + "cliff": 0, + "combat-robot": 0, + "constant-combinator": 0, + "construction-robot": 0, + "container": 0, + "corpse": 0, + "curved-rail": 0, + "decider-combinator": 0, + "deconstructible-tile-proxy": 0, + "electric-energy-interface": 0, + "electric-pole": 0, + "electric-turret": 0, + "entity-ghost": 0, + "explosion": 0, + "fire": 0, + "fish": 0, + "flame-thrower-explosion": 0, + "fluid-turret": 0, + "fluid-wagon": 0, + "flying-text": 0, + "furnace": 0, + "gate": 0, + "generator": 0, + "heat-interface": 0, + "heat-pipe": 0, + "highlight-box": 0, + "infinity-container": 0, + "infinity-pipe": 0, + "inserter": 0, + "item-entity": 0, + "item-request-proxy": 0, + "lab": 0, + "lamp": 0, + "land-mine": 0, + "leaf-particle": 0, + "linked-belt": 0, + "linked-container": 0, + "loader": 0, + "loader-1x1": 0, + "locomotive": 0, + "logistic-container": 0, + "logistic-robot": 0, + "market": 0, + "mining-drill": 0, + "offshore-pump": 0, + "particle": 0, + "particle-source": 0, + "pipe": 0, + "pipe-to-ground": 0, + "player-port": 0, + "power-switch": 0, + "programmable-speaker": 0, + "projectile": 0, + "pump": 0, + "radar": 0, + "rail-chain-signal": 0, + "rail-remnants": 0, + "rail-signal": 0, + "reactor": 0, + "resource": 0, + "roboport": 0, + "rocket-silo": 0, + "rocket-silo-rocket": 0, + "rocket-silo-rocket-shadow": 0, + "simple-entity": 0, + "simple-entity-with-force": 0, + "simple-entity-with-owner": 0, + "smoke": 0, + "smoke-with-trigger": 0, + "solar-panel": 0, + "speech-bubble": 0, + "spider-leg": 0, + "spider-vehicle": 0, + "splitter": 0, + "sticker": 0, + "storage-tank": 0, + "straight-rail": 0, + "stream": 0, + "tile-ghost": 0, + "train-stop": 0, + "transport-belt": 0, + "tree": 0, + "turret": 0, + "underground-belt": 0, + "unit": 0, + "unit-spawner": 0, + "wall": 0, + }, + "equipment": { + "active-defense-equipment": 0, + "battery-equipment": 0, + "belt-immunity-equipment": 0, + "energy-shield-equipment": 0, + "generator-equipment": 0, + "movement-bonus-equipment": 0, + "night-vision-equipment": 0, + "roboport-equipment": 0, + "solar-panel-equipment": 0, + }, + "equipment-category": { + "equipment-category": 0, + }, + "equipment-grid": { + "equipment-grid": 0, + }, + "fluid": { + "fluid": 0, + }, + "font": { + "font": 0, + }, + "fuel-category": { + "fuel-category": 0, + }, + "god-controller": { + "god-controller": 0, + }, + "gui-style": { + "gui-style": 0, + }, + "item": { + "ammo": 0, + "armor": 0, + "blueprint": 0, + "blueprint-book": 0, + "capsule": 0, + "copy-paste-tool": 0, + "deconstruction-item": 0, + "gun": 0, + "item": 0, + "item-with-entity-data": 0, + "item-with-inventory": 0, + "item-with-label": 0, + "item-with-tags": 0, + "mining-tool": 0, + "module": 0, + "rail-planner": 0, + "repair-tool": 0, + "selection-tool": 0, + "spidertron-remote": 0, + "tool": 0, + "upgrade-item": 0, + }, + "item-group": { + "item-group": 0, + }, + "item-subgroup": { + "item-subgroup": 0, + }, + "map-gen-presets": { + "map-gen-presets": 0, + }, + "map-settings": { + "map-settings": 0, + }, + "module-category": { + "module-category": 0, + }, + "mouse-cursor": { + "mouse-cursor": 0, + }, + "noise-expression": { + "noise-expression": 0, + }, + "noise-layer": { + "noise-layer": 0, + }, + "particle": { + "optimized-particle": 0, + }, + "recipe": { + "recipe": 0, + }, + "recipe-category": { + "recipe-category": 0, + }, + "resource-category": { + "resource-category": 0, + }, + "shortcut": { + "shortcut": 0, + }, + "sound": { + "sound": 0, + }, + "spectator-controller": { + "spectator-controller": 0, + }, + "sprite": { + "sprite": 0, + }, + "technology": { + "technology": 0, + }, + "tile": { + "tile": 0, + }, + "tile-effect": { + "tile-effect": 0, + }, + "tips-and-tricks-item": { + "tips-and-tricks-item": 0, + }, + "tips-and-tricks-item-category": { + "tips-and-tricks-item-category": 0, + }, + "trigger-target-type": { + "trigger-target-type": 0, + }, + "trivial-smoke": { + "trivial-smoke": 0, + }, + "tutorial": { + "tutorial": 0, + }, + "utility-constants": { + "utility-constants": 0, + }, + "utility-sounds": { + "utility-sounds": 0, + }, + "utility-sprites": { + "utility-sprites": 0, + }, + "virtual-signal": { + "virtual-signal": 0, + }, + "wind-sound": { + "wind-sound": 0, + }, +}; diff --git a/packages/lib/src/export.ts b/packages/lib/src/export.ts index d461471a..10f06089 100644 --- a/packages/lib/src/export.ts +++ b/packages/lib/src/export.ts @@ -105,16 +105,25 @@ export type FactorioModSettingPrototype = * @see [Data.raw](https://lua-api.factorio.com/stable/types/Data.html#raw) */ export type FactorioPrototypes = Record> +export type FactorioDefinesValue = number | string | { [index: string]: FactorioDefinesValue }; +export type FactorioDefines = { + [index: string]: FactorioDefinesValue, + prototypes: Record>, +}; export type ExportSettings = FactorioPrototypes; export type ExportPrototypes = FactorioPrototypes; export type ExportLocale = [string, string][]; export interface ExportMetadataEntry { - x: number, - y: number, - size: number, + name: string, + type: string, localised_name?: FactorioLocalisedString, - category: string, - path?: string, + icon?: { + x: number, + y: number, + size: number, + path?: string, + }, } -export type ExportMetadata = [string, ExportMetadataEntry][]; +export type ExportMetadata = Record; +export type ExportDefines = FactorioDefines; diff --git a/packages/web_ui/src/components/IconReferencePage.tsx b/packages/web_ui/src/components/IconReferencePage.tsx index bfb896bf..800bfb0c 100644 --- a/packages/web_ui/src/components/IconReferencePage.tsx +++ b/packages/web_ui/src/components/IconReferencePage.tsx @@ -4,11 +4,12 @@ import { Card, Input, Space, Table, Tag, Typography } from "antd"; import { useItemMetadata, useRecipeMetadata, - useSignalMetadata, + useVirtualSignalMetadata, useTechnologyMetadata, - usePlanetMetadata, + useSpaceLocationMetadata, useQualityMetadata, useEntityMetadata, + useFluidMetadata, } from "../model/item_metadata"; import { useExportManifest } from "../model/export_manifest"; import PageHeader from "./PageHeader"; @@ -21,37 +22,45 @@ type IconEntry = { category: string; name: string; cssClass: string; - size: number; path?: string; }; const CATEGORIES = [ - "item", "recipe", "signal", "technology", "planet", "quality", "entity", + "item", "fluid", "recipe", "virtual-signal", "technology", "space-location", "quality", "entity", ] as const; type Category = typeof CATEGORIES[number]; const CATEGORY_LABELS: Record = { item: "Item", + fluid: "Fluid", recipe: "Recipe", - signal: "Signal", + "virtual-signal": "Virtual Signal", technology: "Technology", - planet: "Planet", + "space-location": "Space Location", quality: "Quality", entity: "Entity", }; function useAllIconEntries() { const item = useItemMetadata(); + const fluid = useFluidMetadata(); const recipe = useRecipeMetadata(); - const signal = useSignalMetadata(); + const virtualSignal = useVirtualSignalMetadata(); const technology = useTechnologyMetadata(); - const planet = usePlanetMetadata(); + const spaceLocation = useSpaceLocationMetadata(); const quality = useQualityMetadata(); const entity = useEntityMetadata(); - const byCategory: Record> = { - item, recipe, signal, technology, planet, quality, entity, + const byCategory: Record> = { + item, + fluid, + recipe, + "virtual-signal": virtualSignal, + technology, + "space-location": spaceLocation, + quality, + entity, }; return useMemo(() => { @@ -66,14 +75,13 @@ function useAllIconEntries() { category, name, cssClass: `${category}-${CSS.escape(name)}`, - size: meta.size, - path: meta.path, + path: meta.icon?.path, }); } } return entries; - }, [item, recipe, signal, technology, planet, quality, entity]); + }, [item, fluid, recipe, virtualSignal, technology, spaceLocation, quality, entity]); } export default function IconReferencePage() { diff --git a/packages/web_ui/src/model/item_metadata.ts b/packages/web_ui/src/model/item_metadata.ts index 4235546d..e8e1bc05 100644 --- a/packages/web_ui/src/model/item_metadata.ts +++ b/packages/web_ui/src/model/item_metadata.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useExportManifest } from "./export_manifest"; -import { ExportMetadataEntry } from "@clusterio/lib"; +import { ExportMetadata, ExportMetadataEntry } from "@clusterio/lib"; const metadataCaches = new Map>(); @@ -33,33 +33,35 @@ function useSpriteMetadata(category: string): Map { return; } - const data: [string, ExportMetadataEntry][] = await response.json(); + const data: ExportMetadata = await response.json(); if (!cssInjected) { const sheetUrl = `${staticRoot}static/${exportManifest.assets["spritesheet"]}`; const style = document.createElement("style"); document.head.appendChild(style); - for (const [key, meta] of data) { - const name = key.includes(".") ? key.slice(key.indexOf(".") + 1) : key; - style.sheet!.insertRule(`\ -.${meta.category}-${CSS.escape(name)} { + for (const [baseName, prototypes] of Object.entries(data)) { + for (const prototype of prototypes) { + const icon = prototype.icon; + if (!icon) { + continue; + } + style.sheet!.insertRule(`\ +.${baseName}-${CSS.escape(prototype.name)} { background-image: url("${sheetUrl}"); background-repeat: no-repeat; - background-position: -${meta.x}px -${meta.y}px; - height: ${meta.size}px; - width: ${meta.size}px; + background-position: -${icon.x}px -${icon.y}px; + height: ${icon.size}px; + width: ${icon.size}px; }` - ); + ); + } } cssInjected = true; } const cache = new Map(); - for (const [key, meta] of data) { - if (meta.category === category) { - const name = key.includes(".") ? key.slice(key.indexOf(".") + 1) : key; - cache.set(name, meta); - } + for (const prototype of data[category] ?? []) { + cache.set(prototype.name, prototype); } metadataCaches.set(category, cache); setMetadata(cache); @@ -74,9 +76,10 @@ function useSpriteMetadata(category: string): Map { } export function useItemMetadata() { return useSpriteMetadata("item"); } +export function useFluidMetadata() { return useSpriteMetadata("fluid"); } export function useRecipeMetadata() { return useSpriteMetadata("recipe"); } -export function useSignalMetadata() { return useSpriteMetadata("signal"); } +export function useVirtualSignalMetadata() { return useSpriteMetadata("virtual-signal"); } export function useTechnologyMetadata() { return useSpriteMetadata("technology"); } -export function usePlanetMetadata() { return useSpriteMetadata("planet"); } +export function useSpaceLocationMetadata() { return useSpriteMetadata("space-location"); } export function useQualityMetadata() { return useSpriteMetadata("quality"); } export function useEntityMetadata() { return useSpriteMetadata("entity"); } diff --git a/plugins/subspace_storage/web/index.tsx b/plugins/subspace_storage/web/index.tsx index 9c81fb02..120d29da 100644 --- a/plugins/subspace_storage/web/index.tsx +++ b/plugins/subspace_storage/web/index.tsx @@ -4,7 +4,7 @@ import { Input, Table, Typography } from "antd"; import * as lib from "@clusterio/lib"; import { BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, - notifyErrorHandler, useItemMetadata, useLocale, + notifyErrorHandler, useItemMetadata, useFluidMetadata, useLocale, } from "@clusterio/web_ui"; import { GetStorageRequest, Item, SetStorageSubscriptionRequest, UpdateStorageEvent } from "../messages"; @@ -34,13 +34,14 @@ function StoragePage() { let control = useContext(ControlContext); let locale = useLocale(); let itemMetadata = useItemMetadata(); + let fluidMetadata = useFluidMetadata(); let storage = useStorage(control); type ItemFilter = ([name, item]: [string, Item]) => boolean; let [filter, setFilter] = useState(null); function getLocaleName(itemName: string) { let localeName = itemName; - let meta = itemMetadata.get(itemName); + let meta = itemMetadata.get(itemName) ?? fluidMetadata.get(itemName); if (meta && meta.localised_name) { // TODO: implement the locale to name conversion. if (typeof meta.localised_name === "string") { @@ -98,10 +99,21 @@ function StoragePage() { }, render: (_, item) => { let localeName = getLocaleName(item[1].name); - let hasMeta = itemMetadata.get(item[1].name); + let type; + let name; + if (itemMetadata.has(item[1].name)) { + type = "item"; + name = item[1].name; + } else if (fluidMetadata.has(item[1].name)) { + type = "fluid"; + name = item[1].name; + } else { + type = "item"; + name = "unknown-item"; + } return <> - + {localeName} ; }, From 433cc3c712a8fc20e193dd59bf8388e904c4e264 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Fri, 13 Mar 2026 18:33:17 +0100 Subject: [PATCH 06/12] Add missing model exports to web_ui package A lot of the models were not available to plugins due to the index page not exposing them on the package. --- packages/web_ui/src/index.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/web_ui/src/index.ts b/packages/web_ui/src/index.ts index 0421bd5b..f26a5ba5 100644 --- a/packages/web_ui/src/index.ts +++ b/packages/web_ui/src/index.ts @@ -4,11 +4,18 @@ export { default as notify, notifyErrorHandler } from "./util/notify"; export { default as BaseWebPlugin } from "./BaseWebPlugin"; export * from "./util/websocket"; -export * from "./model/instance"; +export * from "./model/account"; export * from "./model/host"; +export * from "./model/instance"; +export * from "./model/is_dragging"; export * from "./model/locale"; export * from "./model/item_metadata"; -export * from "./model/account"; +export * from "./model/mod_pack"; +export * from "./model/mods"; +export * from "./model/roles"; +export * from "./model/saves"; +export * from "./model/system"; +export * from "./model/user"; export { default as ControlContext } from "./components/ControlContext"; From 4b85367d4eb613370688023187f14c7581c84765 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Fri, 13 Mar 2026 18:34:17 +0100 Subject: [PATCH 07/12] Expose lib.isDeepStrictEqual This utility is useful when comparing deep structures for equality in places like the web_ui. --- packages/lib/browser.ts | 1 + packages/lib/index.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/lib/browser.ts b/packages/lib/browser.ts index aa0273ea..1d7bc9c9 100644 --- a/packages/lib/browser.ts +++ b/packages/lib/browser.ts @@ -17,6 +17,7 @@ export * from "./src/subscriptions"; export { default as ExponentialBackoff } from "./src/ExponentialBackoff"; export { default as RateLimiter } from "./src/RateLimiter"; export { default as ValueCache } from "./src/ValueCache"; +export { default as isDeepStrictEqual } from "./src/is_deep_strict_equal"; import { checkSingletonImport } from "./src/check_singleton_import"; checkSingletonImport(import.meta.url); diff --git a/packages/lib/index.ts b/packages/lib/index.ts index 3319cc89..48ed7462 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -39,6 +39,7 @@ export { default as ExponentialBackoff } from "./src/ExponentialBackoff"; export { default as ModStore } from "./src/ModStore"; export { default as RateLimiter } from "./src/RateLimiter"; export { default as ValueCache } from "./src/ValueCache"; +export { default as isDeepStrictEqual } from "./src/is_deep_strict_equal"; import { checkSingletonImport } from "./src/check_singleton_import"; checkSingletonImport(__filename); From 0d28a3ac3c1784700f3980db7e10974d7218e1fe Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Fri, 13 Mar 2026 18:51:59 +0100 Subject: [PATCH 08/12] Refactor web_ui handling of exports The handling of exported data such as locales and icons in web_ui was based on a model where there is only one set of data exported per cluster. This model changed to have one set of exports per mod pack when mod packs were introduced, but the existing web_ui code was bodged to use exports from a "default mod pack" on the controller. Refactor the code loading exported data to take the mod pack as an argument for selecting which exported data to load. The different kinds of prototype metadata that can be loaded is consolidated into one `useExportPrototypeMetadata` hook. The logic for displaying icons is put into a `FactorioIcon` component. And the obsolete `useExportManifest` hook is removed. These changes allow the `/icon-reference` page to display exports on a per mod pack basis. Which has now been linked on the bottom of the mod pack view page. --- packages/controller/src/routes.ts | 2 +- .../web_ui/src/components/FactorioIcon.tsx | 11 ++ .../src/components/IconReferencePage.tsx | 185 ++++++------------ .../web_ui/src/components/ModPackViewPage.tsx | 7 + packages/web_ui/src/index.ts | 9 +- packages/web_ui/src/model/export_locale.ts | 13 ++ packages/web_ui/src/model/export_manifest.ts | 27 --- .../src/model/export_prototype_metadata.ts | 17 ++ packages/web_ui/src/model/item_metadata.ts | 85 -------- packages/web_ui/src/model/locale.ts | 29 --- packages/web_ui/src/model/mod_pack.ts | 10 + packages/web_ui/src/pages.tsx | 11 +- .../src/store/default_mod_pack_store.ts | 51 +++++ .../web_ui/src/store/export_locale_stores.ts | 61 ++++++ .../store/export_prototype_metadata_store.ts | 126 ++++++++++++ packages/web_ui/src/style.css | 9 +- plugins/subspace_storage/web/index.tsx | 34 ++-- plugins/subspace_storage/web/style.css | 4 +- 18 files changed, 391 insertions(+), 300 deletions(-) create mode 100644 packages/web_ui/src/components/FactorioIcon.tsx create mode 100644 packages/web_ui/src/model/export_locale.ts delete mode 100644 packages/web_ui/src/model/export_manifest.ts create mode 100644 packages/web_ui/src/model/export_prototype_metadata.ts delete mode 100644 packages/web_ui/src/model/item_metadata.ts delete mode 100644 packages/web_ui/src/model/locale.ts create mode 100644 packages/web_ui/src/store/default_mod_pack_store.ts create mode 100644 packages/web_ui/src/store/export_locale_stores.ts create mode 100644 packages/web_ui/src/store/export_prototype_metadata_store.ts diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts index 8c461c9d..b9fba89b 100644 --- a/packages/controller/src/routes.ts +++ b/packages/controller/src/routes.ts @@ -696,12 +696,12 @@ export const webRoutes = [ "/instances/:id/view", "/mods", "/mods/mod-packs/:id/view", + "/mods/mod-packs/:id/icon-reference", "/users", "/users/:id/view", "/roles", "/roles/:id/view", "/plugins", "/plugins/:name/view", - "/icon-reference", "/error", ]; diff --git a/packages/web_ui/src/components/FactorioIcon.tsx b/packages/web_ui/src/components/FactorioIcon.tsx new file mode 100644 index 00000000..a7cd1487 --- /dev/null +++ b/packages/web_ui/src/components/FactorioIcon.tsx @@ -0,0 +1,11 @@ +import { PrototypeMetadataEntry } from "../store/export_prototype_metadata_store"; + +export default function FactorioIcon(props: { modPackId: number | undefined, prototype?: PrototypeMetadataEntry }) { + return ; +} diff --git a/packages/web_ui/src/components/IconReferencePage.tsx b/packages/web_ui/src/components/IconReferencePage.tsx index 800bfb0c..de5263b0 100644 --- a/packages/web_ui/src/components/IconReferencePage.tsx +++ b/packages/web_ui/src/components/IconReferencePage.tsx @@ -1,117 +1,56 @@ -import React, { useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Card, Input, Space, Table, Tag, Typography } from "antd"; -import { - useItemMetadata, - useRecipeMetadata, - useVirtualSignalMetadata, - useTechnologyMetadata, - useSpaceLocationMetadata, - useQualityMetadata, - useEntityMetadata, - useFluidMetadata, -} from "../model/item_metadata"; -import { useExportManifest } from "../model/export_manifest"; +import { useExportPrototypeMetadata } from "../model/export_prototype_metadata"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; +import FactorioIcon from "./FactorioIcon"; +import { useParams } from "react-router-dom"; +import { useModPack } from "../model/mod_pack"; +import { PrototypeMetadataEntry } from "../store/export_prototype_metadata_store"; const { Text } = Typography; -type IconEntry = { - key: string; - category: string; - name: string; - cssClass: string; - path?: string; -}; +function filterPrototypes( + prototypes: Map> | undefined, + filter: string +) { + const query = filter.trim().toLowerCase(); + return new Map( + [...prototypes ?? []].map(([baseType, entries]) => [ + baseType, + new Map([...entries.entries()].filter(([name, entry]) => ( + `${entry.base_type}.${name}`.includes(query) + || `${entry.type}.${name}`.includes(query) + ))), + ]), + ); +} -const CATEGORIES = [ - "item", "fluid", "recipe", "virtual-signal", "technology", "space-location", "quality", "entity", -] as const; - -type Category = typeof CATEGORIES[number]; - -const CATEGORY_LABELS: Record = { - item: "Item", - fluid: "Fluid", - recipe: "Recipe", - "virtual-signal": "Virtual Signal", - technology: "Technology", - "space-location": "Space Location", - quality: "Quality", - entity: "Entity", -}; - -function useAllIconEntries() { - const item = useItemMetadata(); - const fluid = useFluidMetadata(); - const recipe = useRecipeMetadata(); - const virtualSignal = useVirtualSignalMetadata(); - const technology = useTechnologyMetadata(); - const spaceLocation = useSpaceLocationMetadata(); - const quality = useQualityMetadata(); - const entity = useEntityMetadata(); - - const byCategory: Record> = { - item, - fluid, - recipe, - "virtual-signal": virtualSignal, - technology, - "space-location": spaceLocation, - quality, - entity, - }; - - return useMemo(() => { - const entries: IconEntry[] = []; - - for (const category of CATEGORIES) { - const names = [...byCategory[category].keys()].sort(); - for (const name of names) { - const meta = byCategory[category].get(name)!; - entries.push({ - key: `${category}:${name}`, - category, - name, - cssClass: `${category}-${CSS.escape(name)}`, - path: meta.icon?.path, - }); - } - } - - return entries; - }, [item, fluid, recipe, virtualSignal, technology, spaceLocation, quality, entity]); +function countPrototypes( + prototypes: Map> | undefined, +) { + return prototypes ? [...prototypes.values()].reduce((a, e) => a + e.size, 0) : 0; } export default function IconReferencePage() { + let params = useParams(); + let modPackId = Number(params.id); + const [filter, setFilter] = useState(""); const [expandedGroups, setExpandedGroups] = useState>(new Set()); - const allEntries = useAllIconEntries(); + const [modPack] = useModPack(modPackId); + const manifest = modPack?.exportManifest; + const prototypes = useExportPrototypeMetadata(modPack); + const filtered = useMemo(() => filterPrototypes(prototypes, filter), [prototypes, filter]); + const groups = useMemo(() => ( + [...filtered] + .map(([baseType, entries]) => ({ baseType, entries: [...entries.values()] })) + .filter(group => group.entries.length) + ), [filtered]); - const filtered = useMemo(() => { - const q = filter.trim().toLowerCase(); - if (!q) { - return allEntries; - } - return allEntries.filter(e => e.name.toLowerCase().includes(q) || e.cssClass.toLowerCase().includes(q) - ); - }, [allEntries, filter]); - - const groups = useMemo(() => { - const map = new Map(); - for (const cat of CATEGORIES) { - map.set(cat, []); - } - for (const entry of filtered) { - map.get(entry.category)!.push(entry); - } - return CATEGORIES.map(cat => ({ category: cat, entries: map.get(cat)! })) - .filter(g => g.entries.length > 0); - }, [filtered]); - - const visibleCount = filtered.length; - const totalCount = allEntries.length; + const visibleCount = countPrototypes(filtered); + const totalCount = countPrototypes(prototypes); const autoExpand = visibleCount <= 20; const isExpanded = (category: string) => autoExpand || expandedGroups.has(category); @@ -131,11 +70,10 @@ export default function IconReferencePage() { const columns = [ { title: "Icon", - dataIndex: "cssClass", key: "icon", width: 48, - render: (_: string, record: IconEntry) => ( -
+ render: (prototype: PrototypeMetadataEntry) => ( + ), }, { @@ -146,19 +84,9 @@ export default function IconReferencePage() { {name} ), }, - { - title: "Usage", - dataIndex: "cssClass", - key: "usage", - render: (cssClass: string) => ( - - {`
`} - - ), - }, { title: "Path", - dataIndex: "path", + dataIndex: ["icon", "path"], key: "path", render: (p: string | undefined) => (p ? {p} @@ -166,13 +94,17 @@ export default function IconReferencePage() { }, ]; - const manifest = useExportManifest(); - + let nav = [ + { name: "Mods", path: "/mods" }, + { name: "Mod Packs" }, + { name: modPack?.name ?? String(modPackId), path: `/mods/mod-packs/${modPackId}/view` }, + { name: "Icon Reference" }, + ]; return ( - +
- {!manifest && ( + {!modPack?.exportManifest && ( No export data available. Run export-data on an instance to generate icons. @@ -188,7 +120,7 @@ export default function IconReferencePage() {
)}
- Icons: + Prototypes: {totalCount}
@@ -196,7 +128,7 @@ export default function IconReferencePage() { setFilter(e.target.value)} allowClear @@ -208,13 +140,12 @@ export default function IconReferencePage() { : `${visibleCount} / ${totalCount} icons`}
- {groups.map(({ category, entries }) => { - const label = CATEGORY_LABELS[category as Category] ?? category; - const expanded = isExpanded(category); + {groups.map(({ baseType, entries }) => { + const expanded = isExpanded(baseType); return ( -
+
toggleGroup(category)} + onClick={() => toggleGroup(baseType)} style={{ display: "flex", alignItems: "center", @@ -230,13 +161,13 @@ export default function IconReferencePage() { {expanded ? "▾" : "►"} - {label} + {baseType} {entries.length}
{expanded && ( - + columns={columns} dataSource={entries} rowKey="key" diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index 8cccaf24..caea5202 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -27,6 +27,7 @@ import PageLayout from "./PageLayout"; import SectionHeader from "./SectionHeader"; import ModDetails from "./ModDetails"; import InputPartialVersion from "./InputPartialVersion"; +import Link from "./Link"; const { logger } = lib; const { Text } = Typography; @@ -1334,6 +1335,12 @@ export default function ModPackViewPage() { prototypes={prototypes} locale={locale} /> + + Inspect exported data + + + Icon reference +
void) => exportLocaleStore.subscribe(modPack?.id, localePath, callback), + [modPack?.id, localePath], + ); + return useSyncExternalStore(subscribe, () => exportLocaleStore.getSnapshot(modPack?.id)); +} diff --git a/packages/web_ui/src/model/export_manifest.ts b/packages/web_ui/src/model/export_manifest.ts deleted file mode 100644 index 5ec39b28..00000000 --- a/packages/web_ui/src/model/export_manifest.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useEffect, useContext, useState } from "react"; - -import * as lib from "@clusterio/lib"; -import ControlContext from "../components/ControlContext"; - -let exportManifestCache: lib.ExportManifest|null = null; -let loaded = false; -export function useExportManifest(): lib.ExportManifest|null { - const control = useContext(ControlContext); - let [exportManifest, setExportManifest] = useState(exportManifestCache); - useEffect(() => { - async function load() { - let modPack = await control.send(new lib.ModPackGetDefaultRequest()); - if (modPack.exportManifest) { - exportManifestCache = modPack.exportManifest; - setExportManifest(exportManifestCache); - } - loaded = true; - } - - if (!loaded) { - load(); - } - }, []); - - return exportManifest; -} diff --git a/packages/web_ui/src/model/export_prototype_metadata.ts b/packages/web_ui/src/model/export_prototype_metadata.ts new file mode 100644 index 00000000..3b1e1fe1 --- /dev/null +++ b/packages/web_ui/src/model/export_prototype_metadata.ts @@ -0,0 +1,17 @@ +import { useCallback, useSyncExternalStore } from "react"; + +import { ModPack } from "@clusterio/lib"; +import { exportPrototypeMetadataStore } from "../store/export_prototype_metadata_store"; + + +/** + * Load the unified spritesheet metadata and inject CSS rules for every icon. + * Required for {@link FactorioIcon}. + */ +export function useExportPrototypeMetadata(modPack: ModPack | undefined) { + const subscribe = useCallback( + (callback: () => void) => exportPrototypeMetadataStore.subscribe(modPack, callback), + [modPack] + ); + return useSyncExternalStore(subscribe, () => exportPrototypeMetadataStore.getSnapshot(modPack?.id)); +} diff --git a/packages/web_ui/src/model/item_metadata.ts b/packages/web_ui/src/model/item_metadata.ts deleted file mode 100644 index e8e1bc05..00000000 --- a/packages/web_ui/src/model/item_metadata.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useEffect, useState } from "react"; - -import { useExportManifest } from "./export_manifest"; -import { ExportMetadata, ExportMetadataEntry } from "@clusterio/lib"; - - -const metadataCaches = new Map>(); -let cssInjected = false; - -/** - * Load the unified spritesheet metadata and inject CSS rules for every icon. - * CSS class name: `.{category}-{CSS.escape(name)}` - * e.g. `.item-iron-plate`, `.recipe-iron-plate`, `.signal-signal-red`, `.planet-nauvis` - */ -function useSpriteMetadata(category: string): Map { - const exportManifest = useExportManifest(); - const [metadata, setMetadata] = useState>( - metadataCaches.get(category) ?? new Map() - ); - - useEffect(() => { - async function load() { - if ( - !exportManifest - || !exportManifest.assets["metadata"] - || !exportManifest.assets["spritesheet"] - ) { - return; - } - - const response = await fetch(`${staticRoot}static/${exportManifest.assets["metadata"]}`); - if (!response.ok) { - return; - } - - const data: ExportMetadata = await response.json(); - - if (!cssInjected) { - const sheetUrl = `${staticRoot}static/${exportManifest.assets["spritesheet"]}`; - const style = document.createElement("style"); - document.head.appendChild(style); - for (const [baseName, prototypes] of Object.entries(data)) { - for (const prototype of prototypes) { - const icon = prototype.icon; - if (!icon) { - continue; - } - style.sheet!.insertRule(`\ -.${baseName}-${CSS.escape(prototype.name)} { - background-image: url("${sheetUrl}"); - background-repeat: no-repeat; - background-position: -${icon.x}px -${icon.y}px; - height: ${icon.size}px; - width: ${icon.size}px; -}` - ); - } - } - cssInjected = true; - } - - const cache = new Map(); - for (const prototype of data[category] ?? []) { - cache.set(prototype.name, prototype); - } - metadataCaches.set(category, cache); - setMetadata(cache); - } - - if (!metadataCaches.has(category)) { - load(); - } - }, [exportManifest]); - - return metadata; -} - -export function useItemMetadata() { return useSpriteMetadata("item"); } -export function useFluidMetadata() { return useSpriteMetadata("fluid"); } -export function useRecipeMetadata() { return useSpriteMetadata("recipe"); } -export function useVirtualSignalMetadata() { return useSpriteMetadata("virtual-signal"); } -export function useTechnologyMetadata() { return useSpriteMetadata("technology"); } -export function useSpaceLocationMetadata() { return useSpriteMetadata("space-location"); } -export function useQualityMetadata() { return useSpriteMetadata("quality"); } -export function useEntityMetadata() { return useSpriteMetadata("entity"); } diff --git a/packages/web_ui/src/model/locale.ts b/packages/web_ui/src/model/locale.ts deleted file mode 100644 index 95b136ef..00000000 --- a/packages/web_ui/src/model/locale.ts +++ /dev/null @@ -1,29 +0,0 @@ -import React, { useEffect, useState } from "react"; - -import { useExportManifest } from "./export_manifest"; - - -let localeCache: Map|null = null; -export function useLocale(): Map { - let exportManifest = useExportManifest(); - let [locale, setLocale] = useState>(localeCache || new Map()); - useEffect(() => { - async function load() { - if (!exportManifest?.assets["locale"]) { - return; - } - let response = await fetch(`${staticRoot}static/${exportManifest.assets["locale"]}`); - if (response.ok) { - let data = await response.json(); - localeCache = new Map(data); - setLocale(localeCache); - } - } - - if (!localeCache) { - load(); - } - }, [exportManifest]); - - return locale; -} diff --git a/packages/web_ui/src/model/mod_pack.ts b/packages/web_ui/src/model/mod_pack.ts index 927a9a75..a3b63bb2 100644 --- a/packages/web_ui/src/model/mod_pack.ts +++ b/packages/web_ui/src/model/mod_pack.ts @@ -1,5 +1,7 @@ import { useCallback, useContext, useSyncExternalStore } from "react"; import ControlContext from "../components/ControlContext"; +import { defaultModPackStore } from "../store/default_mod_pack_store"; + export function useModPack(id?: number) { const [modPacks, synced] = useModPacks(); @@ -11,3 +13,11 @@ export function useModPacks() { const subscribe = useCallback((callback: () => void) => control.modPacks.subscribe(callback), [control]); return useSyncExternalStore(subscribe, () => control.modPacks.getSnapshot()); } + +export function useDefaultModPack() { + const control = useContext(ControlContext); + const subscribe = useCallback( + (callback: () => void) => defaultModPackStore.subscribe(control, callback), [control], + ); + return useSyncExternalStore(subscribe, () => defaultModPackStore.getSnapshot()); +} diff --git a/packages/web_ui/src/pages.tsx b/packages/web_ui/src/pages.tsx index f3f36a35..f2187c45 100644 --- a/packages/web_ui/src/pages.tsx +++ b/packages/web_ui/src/pages.tsx @@ -18,6 +18,8 @@ import PluginViewPage from "./components/PluginViewPage"; import ErrorPage from "./components/ErrorPage"; import IconReferencePage from "./components/IconReferencePage"; +// When adding or editing page paths here the corresponding webRoutes entry +// in packages/controller/src/routes.ts also needs to be updated. export const pages: PluginPage[] = [ { path: "/controller", @@ -57,6 +59,11 @@ export const pages: PluginPage[] = [ sidebarPath: "/mods", content: , }, + { + path: "/mods/mod-packs/:id/icon-reference", + sidebarPath: "/mods", + content: , + }, { path: "/users", sidebarName: "Users", @@ -89,10 +96,6 @@ export const pages: PluginPage[] = [ sidebarPath: "/plugins", content: , }, - { - path: "/icon-reference", - content: , - }, { path: "/error", content: , diff --git a/packages/web_ui/src/store/default_mod_pack_store.ts b/packages/web_ui/src/store/default_mod_pack_store.ts new file mode 100644 index 00000000..4b92f30a --- /dev/null +++ b/packages/web_ui/src/store/default_mod_pack_store.ts @@ -0,0 +1,51 @@ +import { ModPackGetDefaultRequest, type ModPack, isDeepStrictEqual } from "@clusterio/lib"; +import type { Control } from "../util/websocket"; + +// TODO: replace this super duper expensive method with listening to a default mod pack id event. +class DefaultModPackStore { + refCount = 0; + interval: undefined | ReturnType; + modPack: undefined | ModPack; + callbacks: (() => void)[] = []; + + fetchDefaultModPack(control: Control) { + control.send(new ModPackGetDefaultRequest()).then(modPack => { + if (!isDeepStrictEqual(this.modPack, modPack)) { + this.modPack = modPack; + for (const callback of this.callbacks) { + callback(); + } + } + }).catch(() => { + if (this.modPack !== undefined) { + this.modPack = undefined; + for (const callback of this.callbacks) { + callback(); + } + } + }); + } + + subscribe(control: Control, callback: () => void) { + if (!this.refCount) { + const update = () => { this.fetchDefaultModPack(control); }; + update(); + this.interval = setInterval(update, 60e3); + } + this.callbacks.push(callback); + this.refCount += 1; + return () => { + this.callbacks.splice(this.callbacks.indexOf(callback), 1); + this.refCount -= 1; + if (!this.refCount) { + clearInterval(this.interval); + this.interval = undefined; + } + }; + } + + getSnapshot() { + return this.modPack; + } +}; +export const defaultModPackStore = new DefaultModPackStore(); diff --git a/packages/web_ui/src/store/export_locale_stores.ts b/packages/web_ui/src/store/export_locale_stores.ts new file mode 100644 index 00000000..ef07b4b7 --- /dev/null +++ b/packages/web_ui/src/store/export_locale_stores.ts @@ -0,0 +1,61 @@ +import { ExportLocale, logger } from "@clusterio/lib"; +import notify from "../util/notify"; + + +interface ExportLocaleCacheEntry { + callbacks: (() => void)[]; + localePath: string | undefined; + data: Map; +} + +class ExportLocaleStore { + cache = new Map(); + _emptyMap = new Map(); + + async loadLocale(modPackId: number, cacheEntry: ExportLocaleCacheEntry) { + let response = await fetch(`${staticRoot}static/${cacheEntry.localePath}`); + if (response.ok) { + cacheEntry.data = new Map(await response.json() as ExportLocale); + for (const callback of cacheEntry.callbacks) { + callback(); + } + } else { + notify( + `Failed to locale for mod pack ${modPackId}, server returned: ` + + `${response.status} ${response.statusText}` + ); + } + } + + subscribe(modPackId: number | undefined, localePath: string | undefined, callback: () => void) { + if (modPackId === undefined || !localePath) { + return () => {}; + } + + let cacheEntry = this.cache.get(modPackId); + if (!cacheEntry) { + cacheEntry = { + callbacks: [], + localePath: undefined, + data: new Map(), + }; + this.cache.set(modPackId, cacheEntry); + } + if (cacheEntry.localePath !== localePath) { + cacheEntry.localePath = localePath; + this.loadLocale(modPackId, cacheEntry).catch(logger.error); + } + cacheEntry.callbacks.push(callback); + return () => { + cacheEntry.callbacks.splice(cacheEntry.callbacks.indexOf(callback), 1); + }; + } + + getSnapshot(modPackId: number | undefined): ReadonlyMap { + if (modPackId === undefined) { + return this._emptyMap; + } + return this.cache.get(modPackId)?.data ?? this._emptyMap; + } +} +export const exportLocaleStore = new ExportLocaleStore(); diff --git a/packages/web_ui/src/store/export_prototype_metadata_store.ts b/packages/web_ui/src/store/export_prototype_metadata_store.ts new file mode 100644 index 00000000..17030032 --- /dev/null +++ b/packages/web_ui/src/store/export_prototype_metadata_store.ts @@ -0,0 +1,126 @@ +import { ExportMetadata, ExportMetadataEntry, logger, ModPack } from "@clusterio/lib"; +import unknownIconUrl from "../images/unknown-item.png"; +import notify from "../util/notify"; + + +export type PrototypeMetadataEntry = ExportMetadataEntry & { base_type: string }; + +interface MetadataCacheEntry { + callbacks: (() => void)[], + metadataPath: string | undefined, + spritesheetPath: string | undefined, + metadata: Map>, + styleSheet: CSSStyleSheet, +} + +class ExportPrototypeMetadataStore { + cache = new Map(); + async loadMetadata(modPackId: number, cacheEntry: MetadataCacheEntry) { + const response = await fetch(`${staticRoot}static/${cacheEntry.metadataPath}`); + if (!response.ok) { + notify( + `Failed to fetch prototype metadata for mod pack ${modPackId}, server returned: ` + + `${response.status} ${response.statusText}` + ); + // eslint-disable-next-line node/no-sync + cacheEntry.styleSheet.replaceSync(""); + cacheEntry.metadata = new Map(); + for (const callback of cacheEntry.callbacks) { + callback(); + } + return; + } + + const metadata = await response.json() as ExportMetadata; + + const spriteUrl = `${staticRoot}static/${cacheEntry.spritesheetPath}`; + const styleContent: string[] = []; + styleContent.push(`\ +.factorio-icon\ +[data-mod-pack="${modPackId}"] { + background-image: url("${spriteUrl}"); +}`); + for (const [baseName, prototypes] of Object.entries(metadata)) { + for (const prototype of prototypes) { + const icon = prototype.icon; + if (!icon) { + styleContent.push(`\ +.factorio-icon\ +[data-mod-pack="${modPackId}"]\ +[data-type="${baseName}"]\ +[data-name="${CSS.escape(prototype.name)}"] { + background-image: url("${unknownIconUrl}"); + height: 32px; + width: 32px; +}`); + continue; + } + styleContent.push(`\ +.factorio-icon\ +[data-mod-pack="${modPackId}"]\ +[data-type="${baseName}"]\ +[data-name="${CSS.escape(prototype.name)}"] { + background-position: -${icon.x}px -${icon.y}px; + height: ${icon.size}px; + width: ${icon.size}px; +}`); + } + } + + // eslint-disable-next-line node/no-sync + cacheEntry.styleSheet.replaceSync(styleContent.join("\n")); + cacheEntry.metadata = new Map( + Object.entries(metadata) + .map(([baseName, entries]) => [ + baseName, + new Map( + entries.map(entry => [entry.name, { base_type: baseName, ...entry }]) + ), + ]) + ); + for (const callback of cacheEntry.callbacks) { + callback(); + } + } + + subscribe(modPack: ModPack | undefined, callback: () => void) { + if (!modPack) { + return () => {}; + } + + const assets = modPack.exportManifest?.assets; + let cacheEntry = this.cache.get(modPack.id); + if (!cacheEntry) { + const styleSheet = new CSSStyleSheet(); + document.adoptedStyleSheets.push(styleSheet); + cacheEntry = { + callbacks: [], + spritesheetPath: undefined, + metadataPath: undefined, + metadata: new Map(), + styleSheet, + }; + this.cache.set(modPack.id, cacheEntry); + } + if ( + cacheEntry.metadataPath !== assets?.metadata + || cacheEntry.spritesheetPath !== assets?.spritesheet + ) { + cacheEntry.metadataPath = assets?.metadata; + cacheEntry.spritesheetPath = assets?.spritesheet; + this.loadMetadata(modPack.id, cacheEntry).catch(logger.error); + } + cacheEntry.callbacks.push(callback); + return () => { + cacheEntry.callbacks.splice(cacheEntry.callbacks.indexOf(callback), 1); + }; + } + + getSnapshot(modPackId: number | undefined) { + if (modPackId === undefined) { + return undefined; + } + return this.cache.get(modPackId)?.metadata; + } +} +export const exportPrototypeMetadataStore = new ExportPrototypeMetadataStore(); diff --git a/packages/web_ui/src/style.css b/packages/web_ui/src/style.css index 937c4cd9..59be1d2b 100644 --- a/packages/web_ui/src/style.css +++ b/packages/web_ui/src/style.css @@ -320,7 +320,14 @@ body { font-family: monospace; } -.factorio-icon.item-unknown-item { +.factorio-icon { + display: inline-block; + vertical-align: middle; + background-repeat: no-repeat; +} + +/* data-mod-pack might not be present, but we need the specificity to win over it if it is */ +.factorio-icon:is([data-mod-pack],*)[data-unknown="true"] { background: url("./images/unknown-item.png"); height: 32px; width: 32px; diff --git a/plugins/subspace_storage/web/index.tsx b/plugins/subspace_storage/web/index.tsx index 120d29da..63f669d7 100644 --- a/plugins/subspace_storage/web/index.tsx +++ b/plugins/subspace_storage/web/index.tsx @@ -1,10 +1,9 @@ -import React, { useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import { Input, Table, Typography } from "antd"; -import * as lib from "@clusterio/lib"; import { - BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, - notifyErrorHandler, useItemMetadata, useFluidMetadata, useLocale, + BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler, + useExportLocale, useExportPrototypeMetadata, useDefaultModPack, FactorioIcon, } from "@clusterio/web_ui"; import { GetStorageRequest, Item, SetStorageSubscriptionRequest, UpdateStorageEvent } from "../messages"; @@ -32,16 +31,18 @@ function useStorage(control: Control) { function StoragePage() { let control = useContext(ControlContext); - let locale = useLocale(); - let itemMetadata = useItemMetadata(); - let fluidMetadata = useFluidMetadata(); + const modPack = useDefaultModPack(); + let locale = useExportLocale(modPack); + const prototypes = useExportPrototypeMetadata(modPack); + let itemMetadata = prototypes?.get("item"); + let fluidMetadata = prototypes?.get("fluid"); let storage = useStorage(control); type ItemFilter = ([name, item]: [string, Item]) => boolean; let [filter, setFilter] = useState(null); function getLocaleName(itemName: string) { let localeName = itemName; - let meta = itemMetadata.get(itemName) ?? fluidMetadata.get(itemName); + let meta = itemMetadata?.get(itemName) ?? fluidMetadata?.get(itemName); if (meta && meta.localised_name) { // TODO: implement the locale to name conversion. if (typeof meta.localised_name === "string") { @@ -86,6 +87,7 @@ function StoragePage() { /> { let localeName = getLocaleName(item[1].name); - let type; - let name; - if (itemMetadata.has(item[1].name)) { - type = "item"; - name = item[1].name; - } else if (fluidMetadata.has(item[1].name)) { - type = "fluid"; - name = item[1].name; - } else { - type = "item"; - name = "unknown-item"; - } + let name = item[1].name; + const prototype = itemMetadata?.get(name) ?? fluidMetadata?.get(name); return <> - + {localeName} ; }, diff --git a/plugins/subspace_storage/web/style.css b/plugins/subspace_storage/web/style.css index d96377fc..09460697 100644 --- a/plugins/subspace_storage/web/style.css +++ b/plugins/subspace_storage/web/style.css @@ -1,5 +1,3 @@ -.factorio-icon { - display: inline-block; - vertical-align: middle; +.subspace-storage-storage .factorio-icon { margin-right: 0.5em; } From 781b98b1c103e9c91c8dd4520e318a0c6103af51 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Tue, 17 Mar 2026 23:21:56 +0100 Subject: [PATCH 09/12] Explicitly compare refCount with 0 --- packages/web_ui/src/store/default_mod_pack_store.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web_ui/src/store/default_mod_pack_store.ts b/packages/web_ui/src/store/default_mod_pack_store.ts index 4b92f30a..275066cb 100644 --- a/packages/web_ui/src/store/default_mod_pack_store.ts +++ b/packages/web_ui/src/store/default_mod_pack_store.ts @@ -27,7 +27,7 @@ class DefaultModPackStore { } subscribe(control: Control, callback: () => void) { - if (!this.refCount) { + if (this.refCount === 0) { const update = () => { this.fetchDefaultModPack(control); }; update(); this.interval = setInterval(update, 60e3); @@ -37,7 +37,7 @@ class DefaultModPackStore { return () => { this.callbacks.splice(this.callbacks.indexOf(callback), 1); this.refCount -= 1; - if (!this.refCount) { + if (this.refCount === 0) { clearInterval(this.interval); this.interval = undefined; } From d90ac998aaf27df7468259574af219b8d6ae9cf8 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Tue, 17 Mar 2026 23:23:03 +0100 Subject: [PATCH 10/12] Handle incorrect usage of subscribe in stores If the unsubscribe function is called multiple times the indexOf function will return -1 which will end up removing random other subscription callbacks. Prevent the incorrect callabck removial in this case. --- packages/web_ui/src/store/default_mod_pack_store.ts | 5 ++++- packages/web_ui/src/store/export_locale_stores.ts | 5 ++++- packages/web_ui/src/store/export_prototype_metadata_store.ts | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/web_ui/src/store/default_mod_pack_store.ts b/packages/web_ui/src/store/default_mod_pack_store.ts index 275066cb..bd4686fd 100644 --- a/packages/web_ui/src/store/default_mod_pack_store.ts +++ b/packages/web_ui/src/store/default_mod_pack_store.ts @@ -35,7 +35,10 @@ class DefaultModPackStore { this.callbacks.push(callback); this.refCount += 1; return () => { - this.callbacks.splice(this.callbacks.indexOf(callback), 1); + const index = this.callbacks.indexOf(callback); + if (index !== -1) { + this.callbacks.splice(index, 1); + } this.refCount -= 1; if (this.refCount === 0) { clearInterval(this.interval); diff --git a/packages/web_ui/src/store/export_locale_stores.ts b/packages/web_ui/src/store/export_locale_stores.ts index ef07b4b7..f0a10ada 100644 --- a/packages/web_ui/src/store/export_locale_stores.ts +++ b/packages/web_ui/src/store/export_locale_stores.ts @@ -47,7 +47,10 @@ class ExportLocaleStore { } cacheEntry.callbacks.push(callback); return () => { - cacheEntry.callbacks.splice(cacheEntry.callbacks.indexOf(callback), 1); + const index = cacheEntry.callbacks.indexOf(callback); + if (index !== -1) { + cacheEntry.callbacks.splice(index, 1); + } }; } diff --git a/packages/web_ui/src/store/export_prototype_metadata_store.ts b/packages/web_ui/src/store/export_prototype_metadata_store.ts index 17030032..c8222ac6 100644 --- a/packages/web_ui/src/store/export_prototype_metadata_store.ts +++ b/packages/web_ui/src/store/export_prototype_metadata_store.ts @@ -112,7 +112,10 @@ class ExportPrototypeMetadataStore { } cacheEntry.callbacks.push(callback); return () => { - cacheEntry.callbacks.splice(cacheEntry.callbacks.indexOf(callback), 1); + const index = cacheEntry.callbacks.indexOf(callback); + if (index !== -1) { + cacheEntry.callbacks.splice(index, 1); + } }; } From 019737286f96034b9b33289327a1644ab2cc2e5b Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Tue, 17 Mar 2026 23:32:04 +0100 Subject: [PATCH 11/12] Handle missing export in locale and metadata store In the future, removing exports is likely going to be a feature. Handle the case where the export data is removed from a mod pack in the locale and prototype metadata stores. --- packages/web_ui/src/store/export_locale_stores.ts | 8 ++++++++ .../src/store/export_prototype_metadata_store.ts | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/web_ui/src/store/export_locale_stores.ts b/packages/web_ui/src/store/export_locale_stores.ts index f0a10ada..8d3c6d17 100644 --- a/packages/web_ui/src/store/export_locale_stores.ts +++ b/packages/web_ui/src/store/export_locale_stores.ts @@ -13,6 +13,14 @@ class ExportLocaleStore { _emptyMap = new Map(); async loadLocale(modPackId: number, cacheEntry: ExportLocaleCacheEntry) { + if (!cacheEntry.localePath) { + cacheEntry.data = new Map(); + for (const callback of cacheEntry.callbacks) { + callback(); + } + return; + } + let response = await fetch(`${staticRoot}static/${cacheEntry.localePath}`); if (response.ok) { cacheEntry.data = new Map(await response.json() as ExportLocale); diff --git a/packages/web_ui/src/store/export_prototype_metadata_store.ts b/packages/web_ui/src/store/export_prototype_metadata_store.ts index c8222ac6..59169fd3 100644 --- a/packages/web_ui/src/store/export_prototype_metadata_store.ts +++ b/packages/web_ui/src/store/export_prototype_metadata_store.ts @@ -16,6 +16,16 @@ interface MetadataCacheEntry { class ExportPrototypeMetadataStore { cache = new Map(); async loadMetadata(modPackId: number, cacheEntry: MetadataCacheEntry) { + if (!cacheEntry.metadataPath) { + // eslint-disable-next-line node/no-sync + cacheEntry.styleSheet.replaceSync(""); + cacheEntry.metadata = new Map(); + for (const callback of cacheEntry.callbacks) { + callback(); + } + return; + } + const response = await fetch(`${staticRoot}static/${cacheEntry.metadataPath}`); if (!response.ok) { notify( From b3dc262862652820c7289f163712aa353bca1a85 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Thu, 19 Mar 2026 16:10:54 +0100 Subject: [PATCH 12/12] Remove error prone refCount logic This can simply use the length of the callbacks array instead. --- packages/web_ui/src/store/default_mod_pack_store.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/web_ui/src/store/default_mod_pack_store.ts b/packages/web_ui/src/store/default_mod_pack_store.ts index bd4686fd..d7d88184 100644 --- a/packages/web_ui/src/store/default_mod_pack_store.ts +++ b/packages/web_ui/src/store/default_mod_pack_store.ts @@ -3,7 +3,6 @@ import type { Control } from "../util/websocket"; // TODO: replace this super duper expensive method with listening to a default mod pack id event. class DefaultModPackStore { - refCount = 0; interval: undefined | ReturnType; modPack: undefined | ModPack; callbacks: (() => void)[] = []; @@ -27,20 +26,18 @@ class DefaultModPackStore { } subscribe(control: Control, callback: () => void) { - if (this.refCount === 0) { + if (this.callbacks.length === 0) { const update = () => { this.fetchDefaultModPack(control); }; update(); this.interval = setInterval(update, 60e3); } this.callbacks.push(callback); - this.refCount += 1; return () => { const index = this.callbacks.indexOf(callback); if (index !== -1) { this.callbacks.splice(index, 1); } - this.refCount -= 1; - if (this.refCount === 0) { + if (this.callbacks.length === 0) { clearInterval(this.interval); this.interval = undefined; }