// Original deterministic assets, generated without libraries or downloaded art. import { deflateSync } from 'node:zlib'; import * as fs from 'node:fs/promises'; import path from 'node:path'; import { ensureDirectory, relativeName, safePath } from './files.mjs'; import { BODY_PROFILES, playerB3D, soundOGG, SOUND_PROFILES, TOOL_NAMES, toolOBJ } from './media.mjs'; import { ANIMAL_NAMES, BOTANY_CATALOGUE, animalOBJ, contactSheet, legacyPixel, worldArt } from './world-art.mjs'; import { FLORA_NAMES, TREE_SEASONS, SPECIES_ATLAS_NAMES, floraOBJ, treeAtlas } from './flora-art.mjs'; import { CHARACTER_ART_MANIFEST, CHARACTER_OPTIONS, characterArt, characterMesh, compositeCharacter, portraitSheet } from './character-art.mjs'; import { FAUNA_NAMES, FAUNA_ANIMATIONS, FAUNA_ART_MANIFEST, faunaB3D, faunaTexture, faunaPortraitSheet } from './fauna-art.mjs'; function crc32(data) { let crc = 0xffffffff; for (const byte of data) { crc ^= byte; for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); } return (crc ^ 0xffffffff) >>> 0; } function chunk(type, data) { const body = Buffer.concat([Buffer.from(type), data]); const size = Buffer.alloc(4), crc = Buffer.alloc(4); size.writeUInt32BE(data.length); crc.writeUInt32BE(crc32(body)); return Buffer.concat([size, body, crc]); } export function png(width, pixel, height = width) { if (!Number.isInteger(width) || !Number.isInteger(height) || width<1 || height<1 || width>2048 || height>2048 || width*height>1024*1024 || typeof pixel!=='function') throw new Error('Invalid bounded PNG dimensions or pixel callback.'); const rows = Buffer.alloc(height * (width * 4 + 1)); for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { const color = pixel(x, y), offset = y * (width * 4 + 1) + 1 + x * 4; if (!color || color.length!==4 || !Array.from(color).every(n=>Number.isInteger(n) && n>=0 && n<=255)) throw new Error('Invalid RGBA pixel.'); rows.set(color, offset); } const header = Buffer.alloc(13); header.writeUInt32BE(width); header.writeUInt32BE(height, 4); header[8] = 8; header[9] = 6; return Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]), chunk('IHDR', header), chunk('IDAT', deflateSync(rows)), chunk('IEND', Buffer.alloc(0))]); } function inside(x, y, polygon) { let hit = false; for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const [a,b] = polygon[i], [c,d] = polygon[j]; if ((b > y) !== (d > y) && x < (c-a) * (y-b) / (d-b) + a) hit = !hit; } return hit; } const blades = { knife: [[12,17],[24,3],[23,12],[16,21]], axe: [[5,7],[14,3],[22,9],[17,17],[12,16],[11,11],[6,12]], pick: [[4,10],[9,5],[18,5],[27,11],[20,9],[13,9],[8,11]], spear: [[21,11],[28,2],[27,12],[23,15]], }; function icon(name, x, y) { const diagonal = name === 'knife' || name === 'spear'; const shaft = diagonal ? [[4,26],[6,28],[25,9],[23,7]] : [[11,10],[14,9],[27,27],[24,29]]; let color = null; if (inside(x+.5,y+.5,shaft)) color = (x+y)%5 === 0 ? [88,57,39] : [157,112,65]; if (inside(x+.5,y+.5,blades[name])) color = (x+y)%4 === 0 ? [227,233,220] : [159,181,180]; if (color && ((diagonal && x<12) || (!diagonal && y>21)) && (x+y)%3 === 0) color = [211,183,128]; return color ? [...color,255] : [0,0,0,0]; } // Only mod Lua is an input. In particular never traverse repository scripts, // generated assets, tests or symlink/junction targets. Bounds cap accidental input. async function sourceColors(source) { const colors = new Set(['c7aa83','947853']); let entries = 0; async function visit(directory, depth = 0) { if (depth > 32) throw new Error('Mod directory nesting limit exceeded.'); await safePath(directory); for (const name of (await fs.readdir(directory)).sort()) { relativeName(name); if (++entries > 10000) throw new Error('Mod source entry limit exceeded.'); const filename = path.join(directory, name), stat = await fs.lstat(filename); if (stat.isSymbolicLink()) throw new Error('Refusing symbolic link or junction in mod assets input.'); if (stat.isDirectory()) await visit(filename, depth+1); else if (!stat.isFile()) throw new Error('Mod assets input must contain regular files.'); else if (name.endsWith('.lua')) { if (stat.size > 2*1024*1024) throw new Error('Mod Lua input exceeds size limit.'); const text = await fs.readFile(await safePath(filename), 'utf8'); for (const match of text.matchAll(/#[0-9a-fA-F]{6}\b/g)) colors.add(match[0].slice(1).toLowerCase()); if (colors.size > 4096) throw new Error('Mod texture color limit exceeded.'); } } } await visit(path.join(source, 'mods')); return [...colors].sort(); } async function writeAsset(directory, name, bytes) { // Packaging supplies a private staging tree in a trusted parent; guard output // links too, so direct invocation cannot overwrite a linked external asset. const filename = await safePath(path.join(directory, relativeName(name))); await fs.writeFile(filename, bytes, {mode: 0o600}); } // Per-image provenance is included in the hash-verified package, not just in a // source comment. No inference from filename/common-name or unsafe Lua execution. export const BOTANY_ART_MANIFEST = Object.freeze(BOTANY_CATALOGUE.flatMap(({id,latin,group,morphology})=> [false,true].map(dormant=>Object.freeze({id,botanicalLatin:latin,group,morphology, state:dormant?'dormant':'active',filename:`hearth_botany_${id}${dormant?'_dormant':''}.png`, width:32,height:32,license:'MIT',author:'The Last Hearth original procedural art'})))); export async function generateAssets(destination, source) { const colors = await sourceColors(source); const textures = path.join(destination, 'mods/hearth/textures'); await ensureDirectory(textures); for (const color of colors) { const image = png(32, (x,y)=>legacyPixel(color,x,y)); await writeAsset(textures, `hearth_${color}.png`, image); } const art = worldArt(); await writeAsset(destination, 'botany-art.json', JSON.stringify({format:1,images:BOTANY_ART_MANIFEST},null,2)+'\n'); for (const season of TREE_SEASONS) { const trees = treeAtlas(art, season), suffix = season==='summer'?'':`_${season}`; await writeAsset(textures, `hearth_tree_atlas${suffix}.png`, png(trees.width, (x,y)=>trees.data.subarray((y*trees.width+x)*4,(y*trees.width+x+1)*4), trees.height)); } for (const name of SPECIES_ATLAS_NAMES) { const match=/^([a-z]+(?:_[a-z]+)*)_atlas(?:_(spring|autumn|winter))?$/.exec(name); if(!match) throw new Error('Invalid species atlas name'); const [,species,season='summer']=match, trees=treeAtlas(art,season,species); await writeAsset(textures, `hearth_${name}.png`, png(trees.width, (x,y)=>trees.data.subarray((y*trees.width+x)*4,(y*trees.width+x+1)*4), trees.height)); } for (const [name, image] of art) await writeAsset(textures, name, png(image.width, (x,y)=>image.data.subarray((y*image.width+x)*4,(y*image.width+x+1)*4), image.height)); // The client uses this engine-defined filename, not the journal's named tile. const crosshair = art.get('hearth_crosshair.png'); await writeAsset(textures, 'crosshair.png', png(crosshair.width, (x,y)=>crosshair.data.subarray((y*crosshair.width+x)*4,(y*crosshair.width+x+1)*4), crosshair.height)); await writeAsset(textures, 'hearth_art_contact_sheet.png', artContactSheetPNG(art)); for (const name of TOOL_NAMES) { await writeAsset(textures, `hearth_icon_${name}.png`, png(32,(x,y)=>icon(name,x,y))); } const encode = image => png(image.width, (x,y)=>image.data.subarray((y*image.width+x)*4,(y*image.width+x+1)*4),image.height); for(const id of FAUNA_NAMES) await writeAsset(textures,`hearth_fauna_${id}.png`,encode(faunaTexture(id))); await writeAsset(textures,'hearth_fauna_contact_sheet.png',encode(faunaPortraitSheet())); await writeAsset(destination,'fauna-art.json',JSON.stringify({format:1,animations:FAUNA_ANIMATIONS, animals:FAUNA_ART_MANIFEST,portraitSheet:{filename:'hearth_fauna_contact_sheet.png',width:512,height:768, frame:9,columns:4,cellSize:128,order:FAUNA_NAMES}},null,2)+'\n'); for (const [name,image] of characterArt()) await writeAsset(textures,name,encode(image)); await writeAsset(textures,'hearth_person.png',encode(compositeCharacter())); await writeAsset(textures,'hearth_character_contact_sheet.png',encode(portraitSheet())); await writeAsset(destination,'character-art.json',JSON.stringify({format:1,options:CHARACTER_OPTIONS, meshes:BODY_PROFILES.map(body=>({body,filename:characterMesh(body)})),images:CHARACTER_ART_MANIFEST},null,2)+'\n'); // A low-poly original player silhouette; stable engine mesh instead of a billboard. // Kept for older callers; M1 uses hearth_person.b3d and hearth_person.png. const models = path.join(destination, 'mods/hearth/models'); await ensureDirectory(models); for (const name of FLORA_NAMES) await writeAsset(models, `hearth_${name}.obj`, floraOBJ(name)); for (const name of ANIMAL_NAMES) await writeAsset(models, `hearth_${name}.obj`, animalOBJ(name)); for (const id of FAUNA_NAMES) await writeAsset(models,`hearth_fauna_${id}.b3d`,faunaB3D(id)); let vertices = 0; const obj = ['# Original Last Hearth block character', 'vt 0 0','vt 1 0','vt 1 1','vt 0 1']; function box(x0,y0,z0,x1,y1,z1) { for(const v of [[x0,y0,z0],[x1,y0,z0],[x1,y1,z0],[x0,y1,z0], [x0,y0,z1],[x1,y0,z1],[x1,y1,z1],[x0,y1,z1]]) obj.push(`v ${v.join(' ')}`); for(const f of [[1,4,3,2],[5,6,7,8],[1,2,6,5],[4,8,7,3],[1,5,8,4],[2,3,7,6]]) obj.push(`f ${f.map((n,i)=>`${vertices+n}/${i+1}`).join(' ')}`); vertices += 8; } box(-2.3,8,-1.3,2.3,14,1.3); box(-2,14,-2,2,18,2); box(-4,8,-1,-2.3,14,1); box(2.3,8,-1,4,14,1); box(-2.2,0,-1.2,-0.2,8,1.2); box(0.2,0,-1.2,2.2,8,1.2); await writeAsset(models, 'hearth_person.obj', obj.join('\n')+'\n'); for (const body of BODY_PROFILES) await writeAsset(models,characterMesh(body),playerB3D(body)); for (const name of TOOL_NAMES) await writeAsset(models, `hearth_${name}.obj`, toolOBJ(name)); const sounds = path.join(destination, 'mods/hearth/sounds'); await ensureDirectory(sounds); for (const name of Object.keys(SOUND_PROFILES).sort()) { await writeAsset(sounds, name+'.ogg', await soundOGG(name)); // Retire only our former generated WAVs when regenerating an existing tree. const legacy = await safePath(path.join(sounds, name+'.wav')); try { if (!(await fs.lstat(legacy)).isFile()) throw new Error('Legacy sound must be a regular file.'); await fs.unlink(legacy); } catch (error) { if (error.code !== 'ENOENT') throw error; } } } // Pure export for review tooling; generateAssets writes it via the same guarded // output path as runtime textures. It never writes into the source worktree. export function artContactSheetPNG(art = worldArt()) { const image = contactSheet(art); return png(image.width, (x,y)=>image.data.subarray((y*image.width+x)*4,(y*image.width+x+1)*4), image.height); }