diff --git a/docs/equipment-icon-quality-audit.md b/docs/equipment-icon-quality-audit.md
new file mode 100644
index 0000000..9a4803a
--- /dev/null
+++ b/docs/equipment-icon-quality-audit.md
@@ -0,0 +1,81 @@
+# Equipment Icon Quality Audit
+
+Generated by `scripts/audit-equipment-icon-quality.mjs`.
+
+## Summary
+
+- Catalog items: 23
+- Generated procedural item textures: 23
+- Slots: accessory 7, armor 5, weapon 11
+- Ranks: common 11, treasure 12
+- Texture size: 64px
+- Drawing grid: 28 units scaled 2.29x into the texture
+- Runtime item icon scene: `src/game/scenes/CampScene.ts`
+- Observed runtime display sizes: `14px`, `20px`, `21px`, `22px`, `30px`
+- Direct item texture render sites found: 5
+
+## Main Findings
+
+- Equipment item icons are generated at runtime in `BootScene`, not stored as individual raster images.
+- The 64px textures are drawn from a compact 28-unit source grid, so they are crisp but intentionally simple.
+- Battle combat preview mostly uses the high-resolution `battle-ui-icons` slot frames and item names; per-item textures appear primarily in camp/sortie equipment UI.
+- Treasure/common distinction often relies on UI frame color plus a small palette change, not a strong unique silhouette.
+- Reused helper silhouettes are the strongest quality risk because several different items collapse to the same shape at 14-22px.
+
+## First Improvement Target
+
+- Target: weapon spear/polearm family
+- Item ids: `green-dragon-glaive`, `serpent-spear`, `sky-piercer-halberd`, `iron-spear`, `western-cavalry-spear`
+- Reason: These are high-frequency equipment surfaces and several treasure/common weapons share a thin diagonal shaft silhouette; upgrading this group gives the clearest identity gain in sortie prep and equipment swap screens.
+
+## Follow-Up Targets
+
+- armor silhouettes: `oath-robe`, `reinforced-lamellar`, `cloth-armor`, `lamellar-armor`, `rebel-vest`
+ - Armor and robes are readable but depend on simple triangular torsos and small color accents.
+- accessory charms and tokens: `bravery-token`, `mountain-guide-scroll`, `peach-charm`, `war-manual`, `wind-quiver`, `grain-pouch`, `yellow-scarf-charm`
+ - Accessories are the smallest UI read and several use compact badge-like shapes.
+
+## Highest-Risk Shared Silhouette Groups
+
+| score | signature | slots | ranks | items |
+| ---: | --- | --- | --- | --- |
+| 15 | `drawSpearIcon` | weapon | common, treasure | `serpent-spear`, `iron-spear`, `western-cavalry-spear` |
+| 8 | `drawArmorIcon` | armor | common, treasure | `reinforced-lamellar`, `lamellar-armor` |
+| 8 | `drawCharmIcon` | accessory | common, treasure | `peach-charm`, `yellow-scarf-charm` |
+| 8 | `drawPolearmIcon` | weapon | treasure | `green-dragon-glaive`, `sky-piercer-halberd` |
+| 8 | `drawRobeIcon` | armor | common, treasure | `oath-robe`, `cloth-armor` |
+
+## Full Item Inventory
+
+| item id | slot | rank | draw helpers | calls | primitives | colors | concerns |
+| --- | --- | --- | --- | ---: | ---: | ---: | --- |
+| `green-dragon-glaive` | weapon | treasure | `drawPolearmIcon` | 1 | 11 | 2 | single helper silhouette
low primitive detail |
+| `serpent-spear` | weapon | treasure | `drawSpearIcon` | 1 | 7 | 2 | single helper silhouette
low primitive detail |
+| `sky-piercer-halberd` | weapon | treasure | `drawPolearmIcon` | 1 | 11 | 2 | single helper silhouette
low primitive detail |
+| `twin-oath-blades` | weapon | treasure | `drawSwordIcon`
`drawSwordIcon` | 2 | 32 | 3 | none |
+| `white-feather-fan` | weapon | treasure | `drawFanIcon` | 1 | 19 | 2 | single helper silhouette |
+| `iron-spear` | weapon | common | `drawSpearIcon` | 1 | 7 | 2 | single helper silhouette
low primitive detail |
+| `leader-axe` | weapon | common | `drawAxeIcon` | 1 | 7 | 2 | single helper silhouette
low primitive detail |
+| `short-bow` | weapon | common | `drawBowIcon` | 1 | 18 | 2 | single helper silhouette |
+| `training-sword` | weapon | common | `drawSwordIcon` | 1 | 16 | 2 | single helper silhouette
low primitive detail |
+| `western-cavalry-spear` | weapon | common | `drawSpearIcon` | 1 | 7 | 2 | single helper silhouette
low primitive detail |
+| `yellow-turban-saber` | weapon | common | `drawSaberIcon` | 1 | 12 | 2 | single helper silhouette
low primitive detail |
+| `oath-robe` | armor | treasure | `drawRobeIcon` | 1 | 7 | 2 | single helper silhouette
low primitive detail |
+| `reinforced-lamellar` | armor | treasure | `drawArmorIcon` | 1 | 12 | 2 | single helper silhouette
low primitive detail |
+| `cloth-armor` | armor | common | `drawRobeIcon` | 1 | 7 | 2 | single helper silhouette
low primitive detail |
+| `lamellar-armor` | armor | common | `drawArmorIcon` | 1 | 12 | 2 | single helper silhouette
low primitive detail |
+| `rebel-vest` | armor | common | `drawVestIcon` | 1 | 4 | 2 | single helper silhouette
low primitive detail |
+| `bravery-token` | accessory | treasure | `drawTokenIcon` | 1 | 4 | 2 | single helper silhouette
low primitive detail |
+| `mountain-guide-scroll` | accessory | treasure | `drawMapScrollIcon` | 1 | 13 | 2 | single helper silhouette
low primitive detail |
+| `peach-charm` | accessory | treasure | `drawCharmIcon` | 1 | 4 | 2 | single helper silhouette
low primitive detail |
+| `war-manual` | accessory | treasure | `drawBookIcon` | 1 | 7 | 2 | single helper silhouette
low primitive detail |
+| `wind-quiver` | accessory | treasure | `drawQuiverIcon` | 1 | 8 | 2 | single helper silhouette
low primitive detail |
+| `grain-pouch` | accessory | common | `drawPouchIcon` | 1 | 7 | 2 | single helper silhouette
low primitive detail |
+| `yellow-scarf-charm` | accessory | common | `drawCharmIcon` | 1 | 4 | 2 | single helper silhouette
low primitive detail |
+
+## Recommended Next Batch Definition
+
+- Scope: replace or enrich the five spear/polearm weapon icons while keeping `item-${itemId}` texture keys stable.
+- Success criteria: each item reads as a distinct weapon silhouette at 20px and 30px, with treasure weapons visibly richer than common weapons.
+- Keep the existing 64px texture size unless a UI-wide equipment icon pass is planned.
+- Add before/after contact sheets and verify the sortie equipment list and equipment swap panel in a desktop browser.
diff --git a/package.json b/package.json
index 78b38b2..b23d71b 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"build": "tsc && vite build",
"generate:audio": "node scripts/generate-bgm.mjs",
"generate:sfx": "node scripts/generate-sfx.mjs",
+ "audit:equipment-icons": "node scripts/audit-equipment-icon-quality.mjs",
"preview": "vite preview --host 0.0.0.0",
"measure:performance": "node scripts/measure-performance.mjs",
"verify:performance": "node scripts/measure-performance.mjs",
diff --git a/scripts/audit-equipment-icon-quality.mjs b/scripts/audit-equipment-icon-quality.mjs
new file mode 100644
index 0000000..0a1b83c
--- /dev/null
+++ b/scripts/audit-equipment-icon-quality.mjs
@@ -0,0 +1,421 @@
+import { readFileSync, writeFileSync } from 'node:fs';
+import { createServer } from 'vite';
+
+const bootScenePath = 'src/game/scenes/BootScene.ts';
+const campScenePath = 'src/game/scenes/CampScene.ts';
+const reportPath = 'docs/equipment-icon-quality-audit.md';
+
+const server = await createServer({
+ logLevel: 'error',
+ server: { middlewareMode: true },
+ appType: 'custom'
+});
+
+try {
+ const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts');
+ const bootSceneSource = readFileSync(bootScenePath, 'utf8');
+ const campSceneSource = readFileSync(campScenePath, 'utf8');
+
+ const iconSourceSize = numberConstant(bootSceneSource, 'itemIconSourceSize');
+ const iconTextureSize = numberConstant(bootSceneSource, 'itemIconTextureSize');
+ const drawFunctions = parseDrawFunctions(bootSceneSource);
+ const iconDefinitions = parseIconDefinitions(bootSceneSource, drawFunctions);
+ const sceneUsage = parseSceneUsage(campSceneSource);
+ const itemRows = collectItemRows(itemCatalog, equipmentSlots, iconDefinitions);
+ const groupedRows = groupBySignature(itemRows);
+ const recommendations = buildRecommendations(itemRows, groupedRows);
+ const report = renderReport({
+ iconSourceSize,
+ iconTextureSize,
+ itemRows,
+ groupedRows,
+ recommendations,
+ sceneUsage
+ });
+
+ writeFileSync(reportPath, report, 'utf8');
+ console.log(`Wrote ${reportPath}`);
+} finally {
+ await server.close();
+}
+
+function numberConstant(source, name) {
+ const match = source.match(new RegExp(`const\\s+${name}\\s*=\\s*(\\d+)`));
+ if (!match) {
+ throw new Error(`Could not find ${name}`);
+ }
+ return Number(match[1]);
+}
+
+function parseIconDefinitions(source, drawFunctions) {
+ const definitions = new Map();
+ const matcher = /this\.createItemIcon\('item-([^']+)'/g;
+ let match;
+
+ while ((match = matcher.exec(source))) {
+ const itemId = match[1];
+ const callText = expressionSpan(source, match.index);
+ const drawCalls = [...callText.matchAll(/this\.(draw[A-Za-z]+Icon)\(([^)]*)\)/g)].map((drawMatch) => ({
+ functionName: drawMatch[1],
+ colors: [...drawMatch[2].matchAll(/0x[0-9a-fA-F]{6}/g)].map((colorMatch) => colorMatch[0].toLowerCase())
+ }));
+ const colors = [...new Set(drawCalls.flatMap((call) => call.colors))].sort();
+ const primitiveCount = drawCalls.reduce((sum, call) => sum + (drawFunctions.get(call.functionName)?.primitiveCount ?? 0), 0);
+ const lineNumber = source.slice(0, match.index).split(/\r?\n/).length;
+
+ definitions.set(itemId, {
+ itemId,
+ lineNumber,
+ drawCalls,
+ drawFunctionNames: drawCalls.map((call) => call.functionName),
+ colors,
+ primitiveCount,
+ signature: drawCalls.map((call) => call.functionName).sort().join('+')
+ });
+ }
+
+ return definitions;
+}
+
+function expressionSpan(source, startIndex) {
+ const openIndex = source.indexOf('(', startIndex);
+ if (openIndex < 0) {
+ throw new Error(`Could not find call expression at ${startIndex}`);
+ }
+
+ let depth = 0;
+ let quote = '';
+ let escaped = false;
+
+ for (let index = openIndex; index < source.length; index += 1) {
+ const char = source[index];
+
+ if (quote) {
+ if (escaped) {
+ escaped = false;
+ } else if (char === '\\') {
+ escaped = true;
+ } else if (char === quote) {
+ quote = '';
+ }
+ continue;
+ }
+
+ if (char === "'" || char === '"' || char === '`') {
+ quote = char;
+ continue;
+ }
+
+ if (char === '(') {
+ depth += 1;
+ } else if (char === ')') {
+ depth -= 1;
+ if (depth === 0) {
+ return source.slice(startIndex, index + 1);
+ }
+ }
+ }
+
+ throw new Error(`Unclosed call expression at ${startIndex}`);
+}
+
+function parseDrawFunctions(source) {
+ const functions = new Map();
+ const matcher = /private\s+(draw[A-Za-z]+Icon)\([^)]*\)\s*\{/g;
+ let match;
+
+ while ((match = matcher.exec(source))) {
+ const functionName = match[1];
+ const body = blockSpan(source, source.indexOf('{', match.index));
+ functions.set(functionName, {
+ functionName,
+ primitiveCount: countGraphicsPrimitives(body),
+ colors: [...new Set([...body.matchAll(/0x[0-9a-fA-F]{6}/g)].map((colorMatch) => colorMatch[0].toLowerCase()))].sort()
+ });
+ }
+
+ return functions;
+}
+
+function blockSpan(source, openIndex) {
+ let depth = 0;
+ let quote = '';
+ let escaped = false;
+
+ for (let index = openIndex; index < source.length; index += 1) {
+ const char = source[index];
+
+ if (quote) {
+ if (escaped) {
+ escaped = false;
+ } else if (char === '\\') {
+ escaped = true;
+ } else if (char === quote) {
+ quote = '';
+ }
+ continue;
+ }
+
+ if (char === "'" || char === '"' || char === '`') {
+ quote = char;
+ continue;
+ }
+
+ if (char === '{') {
+ depth += 1;
+ } else if (char === '}') {
+ depth -= 1;
+ if (depth === 0) {
+ return source.slice(openIndex + 1, index);
+ }
+ }
+ }
+
+ throw new Error(`Unclosed block at ${openIndex}`);
+}
+
+function countGraphicsPrimitives(body) {
+ return [
+ ...body.matchAll(
+ /graphics\.(fillTriangle|fillRect|fillRoundedRect|fillEllipse|fillCircle|lineStyle|beginPath|moveTo|lineTo|strokePath|strokeRect|strokeRoundedRect|strokeCircle)/g
+ )
+ ].length;
+}
+
+function parseSceneUsage(source) {
+ const sizes = new Set();
+ const usageBlocks = [...source.matchAll(/add\.image\([^`]+`item-\$\{[^`]+`[\s\S]{0,240}?setDisplaySize\((\d+),\s*(\d+)\)/g)];
+ const sortieSignature = source.match(/renderSortieEquipmentIcons\([^)]*iconSize\s*=\s*(\d+)/);
+
+ usageBlocks.forEach((match) => {
+ if (match[1] === match[2]) {
+ sizes.add(Number(match[1]));
+ } else {
+ sizes.add(`${match[1]}x${match[2]}`);
+ }
+ });
+
+ if (sortieSignature) {
+ sizes.add(Number(sortieSignature[1]));
+ }
+
+ const directItemImageCount = [...source.matchAll(/add\.image\([^`]+`item-\$\{/g)].length;
+ return {
+ scene: campScenePath,
+ displaySizes: [...sizes].sort((left, right) => Number(left) - Number(right)),
+ directItemImageCount
+ };
+}
+
+function collectItemRows(itemCatalog, equipmentSlots, iconDefinitions) {
+ const slotOrder = new Map(equipmentSlots.map((slot, index) => [slot, index]));
+
+ return Object.entries(itemCatalog)
+ .map(([itemId, item]) => {
+ const icon = iconDefinitions.get(itemId);
+ const concerns = [];
+ if (!icon) {
+ concerns.push('missing generated icon');
+ } else {
+ if (icon.drawCalls.length <= 1) {
+ concerns.push('single helper silhouette');
+ }
+ if (icon.primitiveCount < 18) {
+ concerns.push('low primitive detail');
+ }
+ }
+
+ return {
+ itemId,
+ slot: item.slot,
+ rank: item.rank,
+ attackBonus: item.attackBonus ?? 0,
+ defenseBonus: item.defenseBonus ?? 0,
+ strategyBonus: item.strategyBonus ?? 0,
+ icon,
+ signature: icon?.signature ?? 'missing',
+ drawFunctions: icon?.drawFunctionNames ?? [],
+ drawCalls: icon?.drawCalls.length ?? 0,
+ primitiveCount: icon?.primitiveCount ?? 0,
+ colorCount: icon?.colors.length ?? 0,
+ concerns
+ };
+ })
+ .sort((left, right) => {
+ const slotDiff = (slotOrder.get(left.slot) ?? 99) - (slotOrder.get(right.slot) ?? 99);
+ if (slotDiff !== 0) {
+ return slotDiff;
+ }
+ if (left.rank !== right.rank) {
+ return left.rank === 'treasure' ? -1 : 1;
+ }
+ return left.itemId.localeCompare(right.itemId);
+ });
+}
+
+function groupBySignature(itemRows) {
+ const groups = new Map();
+ itemRows.forEach((row) => {
+ const group = groups.get(row.signature) ?? [];
+ group.push(row);
+ groups.set(row.signature, group);
+ });
+
+ return [...groups.entries()]
+ .map(([signature, rows]) => ({
+ signature,
+ rows,
+ slots: [...new Set(rows.map((row) => row.slot))].sort(),
+ ranks: [...new Set(rows.map((row) => row.rank))].sort(),
+ itemIds: rows.map((row) => row.itemId),
+ score: groupRiskScore(rows)
+ }))
+ .sort((left, right) => right.score - left.score || right.rows.length - left.rows.length || left.signature.localeCompare(right.signature));
+}
+
+function groupRiskScore(rows) {
+ const slots = new Set(rows.map((row) => row.slot));
+ const ranks = new Set(rows.map((row) => row.rank));
+ let score = rows.length;
+
+ if (rows.length >= 3) {
+ score += 3;
+ }
+ if (slots.has('weapon')) {
+ score += 3;
+ }
+ if (ranks.has('treasure') && ranks.has('common')) {
+ score += 3;
+ }
+ if (rows.some((row) => row.primitiveCount < 18)) {
+ score += 2;
+ }
+ if (rows.some((row) => row.rank === 'treasure')) {
+ score += 1;
+ }
+
+ return score;
+}
+
+function buildRecommendations(itemRows, groupedRows) {
+ const weaponPolearmIds = itemRows
+ .filter((row) => row.slot === 'weapon' && ['drawSpearIcon', 'drawPolearmIcon'].some((name) => row.drawFunctions.includes(name)))
+ .map((row) => row.itemId);
+ const armorIds = itemRows.filter((row) => row.slot === 'armor').map((row) => row.itemId);
+ const accessoryIds = itemRows.filter((row) => row.slot === 'accessory').map((row) => row.itemId);
+
+ return {
+ firstTargetTitle: 'weapon spear/polearm family',
+ firstTargetIds: weaponPolearmIds,
+ firstTargetReason:
+ 'These are high-frequency equipment surfaces and several treasure/common weapons share a thin diagonal shaft silhouette; upgrading this group gives the clearest identity gain in sortie prep and equipment swap screens.',
+ followUps: [
+ {
+ title: 'armor silhouettes',
+ itemIds: armorIds,
+ reason: 'Armor and robes are readable but depend on simple triangular torsos and small color accents.'
+ },
+ {
+ title: 'accessory charms and tokens',
+ itemIds: accessoryIds,
+ reason: 'Accessories are the smallest UI read and several use compact badge-like shapes.'
+ }
+ ],
+ topRiskGroups: groupedRows.slice(0, 5)
+ };
+}
+
+function renderReport({ iconSourceSize, iconTextureSize, itemRows, groupedRows, recommendations, sceneUsage }) {
+ const slotCounts = countBy(itemRows, (row) => row.slot);
+ const rankCounts = countBy(itemRows, (row) => row.rank);
+ const generatedCount = itemRows.filter((row) => row.icon).length;
+ const scale = (iconTextureSize / iconSourceSize).toFixed(2);
+
+ return `${[
+ '# Equipment Icon Quality Audit',
+ '',
+ 'Generated by `scripts/audit-equipment-icon-quality.mjs`.',
+ '',
+ '## Summary',
+ '',
+ `- Catalog items: ${itemRows.length}`,
+ `- Generated procedural item textures: ${generatedCount}`,
+ `- Slots: ${formatCounts(slotCounts)}`,
+ `- Ranks: ${formatCounts(rankCounts)}`,
+ `- Texture size: ${iconTextureSize}px`,
+ `- Drawing grid: ${iconSourceSize} units scaled ${scale}x into the texture`,
+ `- Runtime item icon scene: \`${sceneUsage.scene}\``,
+ `- Observed runtime display sizes: ${sceneUsage.displaySizes.map((size) => `\`${size}px\``).join(', ') || 'none found'}`,
+ `- Direct item texture render sites found: ${sceneUsage.directItemImageCount}`,
+ '',
+ '## Main Findings',
+ '',
+ '- Equipment item icons are generated at runtime in `BootScene`, not stored as individual raster images.',
+ '- The 64px textures are drawn from a compact 28-unit source grid, so they are crisp but intentionally simple.',
+ '- Battle combat preview mostly uses the high-resolution `battle-ui-icons` slot frames and item names; per-item textures appear primarily in camp/sortie equipment UI.',
+ '- Treasure/common distinction often relies on UI frame color plus a small palette change, not a strong unique silhouette.',
+ '- Reused helper silhouettes are the strongest quality risk because several different items collapse to the same shape at 14-22px.',
+ '',
+ '## First Improvement Target',
+ '',
+ `- Target: ${recommendations.firstTargetTitle}`,
+ `- Item ids: ${recommendations.firstTargetIds.map((itemId) => `\`${itemId}\``).join(', ')}`,
+ `- Reason: ${recommendations.firstTargetReason}`,
+ '',
+ '## Follow-Up Targets',
+ '',
+ ...recommendations.followUps.flatMap((target) => [
+ `- ${target.title}: ${target.itemIds.map((itemId) => `\`${itemId}\``).join(', ')}`,
+ ` - ${target.reason}`
+ ]),
+ '',
+ '## Highest-Risk Shared Silhouette Groups',
+ '',
+ '| score | signature | slots | ranks | items |',
+ '| ---: | --- | --- | --- | --- |',
+ ...recommendations.topRiskGroups.map(
+ (group) =>
+ `| ${group.score} | \`${group.signature}\` | ${group.slots.join(', ')} | ${group.ranks.join(', ')} | ${group.itemIds.map((itemId) => `\`${itemId}\``).join(', ')} |`
+ ),
+ '',
+ '## Full Item Inventory',
+ '',
+ '| item id | slot | rank | draw helpers | calls | primitives | colors | concerns |',
+ '| --- | --- | --- | --- | ---: | ---: | ---: | --- |',
+ ...itemRows.map((row) =>
+ [
+ `\`${row.itemId}\``,
+ row.slot,
+ row.rank,
+ row.drawFunctions.map((name) => `\`${name}\``).join('
') || 'missing',
+ row.drawCalls,
+ row.primitiveCount,
+ row.colorCount,
+ row.concerns.join('
') || 'none'
+ ].join(' | ')
+ ).map((line) => `| ${line} |`),
+ '',
+ '## Recommended Next Batch Definition',
+ '',
+ '- Scope: replace or enrich the five spear/polearm weapon icons while keeping `item-${itemId}` texture keys stable.',
+ '- Success criteria: each item reads as a distinct weapon silhouette at 20px and 30px, with treasure weapons visibly richer than common weapons.',
+ '- Keep the existing 64px texture size unless a UI-wide equipment icon pass is planned.',
+ '- Add before/after contact sheets and verify the sortie equipment list and equipment swap panel in a desktop browser.',
+ ''
+ ].join('\n')}`;
+}
+
+function countBy(rows, keySelector) {
+ return rows.reduce((counts, row) => {
+ const key = keySelector(row);
+ counts.set(key, (counts.get(key) ?? 0) + 1);
+ return counts;
+ }, new Map());
+}
+
+function formatCounts(counts) {
+ return [...counts.entries()]
+ .sort(([left], [right]) => String(left).localeCompare(String(right)))
+ .map(([key, count]) => `${key} ${count}`)
+ .join(', ');
+}