// Original procedural geometry and audio. Runtime packaging needs no codec. import * as fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createHash, randomInt } from 'node:crypto'; import { createRequire } from 'node:module'; import { spawnSync } from 'node:child_process'; import os from 'node:os'; import { safePath } from './files.mjs'; // Luanti renders mesh coordinates directly: 10 model units = one world node. export const ANIMATIONS = Object.freeze(Object.fromEntries(Object.entries({ idle: [0, 19, 12], walk: [20, 39, 24], run: [40, 59, 32], strike: [60, 69, 28], guard: [70, 79, 12], swim: [80, 99, 20], }).map(([name, range]) => [name, Object.freeze(range)]))); export const PLAYER_HEIGHT = 17.5; export const SAMPLE_RATE = 22050; const i32 = (...values) => { const bytes = Buffer.alloc(values.length * 4); values.forEach((value, i) => bytes.writeInt32LE(value, i * 4)); return bytes; }; const f32 = (...values) => { const bytes = Buffer.alloc(values.length * 4); values.forEach((value, i) => bytes.writeFloatLE(value, i * 4)); return bytes; }; const string = value => Buffer.from(value + '\0', 'ascii'); const chunk = (tag, ...parts) => { const data = Buffer.concat(parts); return Buffer.concat([Buffer.from(tag, 'ascii'), i32(data.length), data]); }; // One 64px atlas, 8px tiles, half-texel inset. Front faces look toward +Z. export const ATLAS = Object.freeze({face: [0, 0], hair: [1, 0], side: [2, 0], tunic: [0, 1], back: [1, 1], sleeve: [2, 1], trousers: [0, 2], boot: [1, 2], skin: [2, 2], metal: [0, 3], wood: [1, 3], wrap: [2, 3]}); function uv(tile) { const [x, y] = ATLAS[tile]; return [[0.5, 7.5], [7.5, 7.5], [7.5, 0.5], [0.5, 0.5]] .map(([u, v]) => [(x * 8 + u) / 64, (y * 8 + v) / 64]); } const faces = [ [[0, 3, 2, 1], [0, 0, -1]], [[4, 5, 6, 7], [0, 0, 1]], [[0, 1, 5, 4], [0, -1, 0]], [[3, 7, 6, 2], [0, 1, 0]], [[0, 4, 7, 3], [-1, 0, 0]], [[1, 2, 6, 5], [1, 0, 0]], ]; function box(vertices, triangles, weights, bone, bounds, tiles, taper = [1, 1]) { const [x0, y0, z0, x1, y1, z1] = bounds; const corners = [[x0,y0,z0],[x1,y0,z0],[x1,y1,z0],[x0,y1,z0], [x0,y0,z1],[x1,y0,z1],[x1,y1,z1],[x0,y1,z1]]; for (const corner of corners) if (corner[1] === y0) { corner[0] = (x0+x1)/2 + (corner[0]-(x0+x1)/2)*taper[0]; corner[2] = (z0+z1)/2 + (corner[2]-(z0+z1)/2)*taper[1]; } faces.forEach(([indices], face) => { const [a,b,c] = indices.map(i => corners[i]); const u = b.map((n,i)=>n-a[i]), v = c.map((n,i)=>n-a[i]); const cross = [u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]]; const normal = cross.map(n=>n/Math.hypot(...cross)); const first = vertices.length, coords = uv(Array.isArray(tiles) ? tiles[face] : tiles); indices.forEach((index, i) => { vertices.push([...corners[index], ...normal, ...coords[i]]); weights[bone].push(first + i); }); triangles.push([first, first + 1, first + 2], [first, first + 2, first + 3]); }); } // Rigidly weighted low-poly limbs are skeletal animation, not vertex snapshots. // Child hand bones carry held meshes through the same strike/guard animation. const skeleton = [ {name: 'Root', parent: null, pos: [0,0,0]}, {name: 'Body', parent: 'Root', pos: [0,8,0]}, {name: 'Head', parent: 'Body', pos: [0,5.5,0]}, {name: 'Arm_Right', parent: 'Body', pos: [-3.1,5,0]}, {name: 'Hand_Right', parent: 'Arm_Right', pos: [0,-4.8,0]}, {name: 'Arm_Left', parent: 'Body', pos: [3.1,5,0]}, {name: 'Hand_Left', parent: 'Arm_Left', pos: [0,-4.8,0]}, {name: 'Leg_Right', parent: 'Root', pos: [-1.15,8,0]}, {name: 'Leg_Left', parent: 'Root', pos: [1.15,8,0]}, ]; function pose(name, frame) { const [clip, [start, end]] = Object.entries(ANIMATIONS).find(([, [a, b]]) => frame >= a && frame <= b); const t = (frame - start) / (end - start), cycle = Math.sin(t * Math.PI * 2); let x = 0, y = 0, z = 0, lift = 0; const right = name.endsWith('Right'), limb = right ? 1 : -1; if (clip === 'idle') { if (name === 'Body') lift = 0.055 * (1 - Math.cos(t * Math.PI * 2)); if (name === 'Head') y = 0.045 * cycle; } else if (clip === 'walk' || clip === 'run') { const amplitude = clip === 'run' ? 0.9 : 0.5; if (name.startsWith('Leg_')) x = cycle * amplitude * limb; if (name.startsWith('Arm_')) x = -cycle * amplitude * limb; if (name === 'Body') { lift = Math.abs(cycle) * (clip === 'run' ? 0.3 : 0.12); x = clip === 'run' ? -0.12 : 0; } } else if (clip === 'strike') { const swing = Math.sin(Math.PI * t); if (name === 'Arm_Right') { x = -2.1 * swing; z = -0.25 * swing; } if (name === 'Body') y = 0.25 * swing; if (name === 'Arm_Left') x = -0.35 * swing; } else if (clip === 'guard') { if (name.startsWith('Arm_')) { x = -1.15 + 0.035 * cycle; z = -limb * 0.35; } if (name === 'Body') x = -0.1; } else if (clip === 'swim') { if (name === 'Body') x = -0.95; if (name.startsWith('Arm_')) { x = -1.5 + cycle * limb * 0.65; z = -limb * 0.25; } if (name.startsWith('Leg_')) x = cycle * limb * 0.35; } // Euler XYZ -> quaternion (B3D order WXYZ). No scale keys, no root motion. const [cx, cy, cz] = [x,y,z].map(a => Math.cos(a / 2)); const [sx, sy, sz] = [x,y,z].map(a => Math.sin(a / 2)); return {lift, rotation: [cx*cy*cz + sx*sy*sz, sx*cy*cz - cx*sy*sz, cx*sy*cz + sx*cy*sz, cx*cy*sz - sx*sy*cz]}; } export const BODY_PROFILES = Object.freeze(['compact', 'lean', 'balanced', 'broad', 'sturdy']); export function playerB3D(profile = 'balanced') { if (!BODY_PROFILES.includes(profile)) throw new Error('Unknown character body profile.'); const width = {compact: .86, lean: .93, balanced: 1, broad: 1.08, sturdy: 1.16}[profile]; const vertices = [], triangles = [], weights = Object.fromEntries(skeleton.map(b => [b.name, []])); box(vertices, triangles, weights, 'Body', [-2.15*width,8,-1.18,2.15*width,13.5,1.18], ['back','tunic','tunic','tunic','tunic','tunic'], [profile === 'broad' ? .9 : .86, .86]); box(vertices, triangles, weights, 'Head', [-1.6,13.5,-1.4,1.6,PLAYER_HEIGHT,1.4], ['hair','face','skin','hair','side','side']); // Original layered fieldwear: separate low-poly mantle, belt and satchel // keep the survivor readable at a distance without changing their hitbox. box(vertices, triangles, weights, 'Body', [-2.15*width,1.2,-1.58,2.15*width,11.8,-1.22], ['back','back','back','back','back','back'], [profile === 'broad' ? .94 : .88, .92]); box(vertices, triangles, weights, 'Body', [-2.2*width,8.05,-1.25,2.2*width,9.05,1.25], ['wrap','wrap','wrap','wrap','wrap','wrap']); box(vertices, triangles, weights, 'Body', [1.35*width,3.8,-1.9,3.35*width,7.9,-1.43], ['wood','wrap','wood','wood','wood','wrap'], [.9,.92]); box(vertices, triangles, weights, 'Head', [-.32,14.2,1.2,.32,15.1,1.5], ['skin','skin','skin','skin','skin','skin'], [.68,.7]); for (const side of [-1,1]) box(vertices, triangles, weights, 'Head', [side<0?-1.6:1.34,14.6,-.15,side<0?-1.34:1.6,15.5,.75], 'skin', [.7,.8]); for (const [side, sign] of [['Right', -1], ['Left', 1]]) { const x = sign * 3.1; box(vertices, triangles, weights, `Arm_${side}`, [x-.78*width,8.6,-.84,x+.78*width,13,.84], 'sleeve', [.9,.95]); box(vertices, triangles, weights, `Hand_${side}`, [x-.7,7.6,-.8,x+.7,8.6,.8], 'skin'); const leg = sign * 1.15; box(vertices, triangles, weights, `Leg_${side}`, [leg-.91*width,1.5,-.95,leg+.91*width,8,.95], 'trousers', [.9,.94]); box(vertices, triangles, weights, `Leg_${side}`, [leg-1,0,-1.1,leg+1,1.5,1.5], 'boot'); } const mesh = chunk('MESH', i32(0), chunk('VRTS', i32(1, 1, 2), ...vertices.map(v => f32(...v))), chunk('TRIS', i32(0), ...triangles.map(t => i32(...t)))); function node(bone) { const keys = []; for (let frame = 0; frame < 100; frame++) { const {lift, rotation} = pose(bone.name, frame); // Irrlicht subtracts one from B3D KEYS frame indices. Lua uses 0..99. keys.push(i32(frame + 1), f32(bone.pos[0], bone.pos[1] + lift, bone.pos[2], ...rotation)); } return chunk('NODE', string(bone.name), f32(...bone.pos, 1,1,1, 1,0,0,0), ...(bone.name === 'Root' ? [mesh, chunk('ANIM', i32(0,100), f32(24))] : []), ...(weights[bone.name].length ? [chunk('BONE', ...weights[bone.name].map(id => Buffer.concat([i32(id), f32(1)])))] : []), chunk('KEYS', i32(5), ...keys), ...skeleton.filter(b => b.parent === bone.name).map(node)); } return chunk('BB3D', i32(1), chunk('TEXS', string('hearth_person.png'), i32(1,2), f32(0,0,1,1,0)), chunk('BRUS', i32(1), string('Hearth cloth and skin'), f32(1,1,1,1,0), i32(1,0,0)), node(skeleton[0])); } export const TOOL_NAMES = Object.freeze(['knife', 'axe', 'pick', 'spear']); export function toolOBJ(name) { if (!TOOL_NAMES.includes(name)) throw new Error('Unknown original tool.'); // Grip at origin; +Y is the blade/shaft axis. Attach to Hand_Right with // rotation {x=90,y=0,z=0} to point along +Z in the neutral player pose. const vertices = [], triangles = [], weights = {tool: []}; const add = (bounds, tile) => box(vertices, triangles, weights, 'tool', bounds, tile); const length = {knife: 1.6, axe: 5.2, pick: 5.2, spear: 10}[name]; add([-.16,-1,-.16,.16,length,.16], 'wood'); add([-.22,-.8,-.22,.22,.8,.22], 'wrap'); if (name === 'axe') add([-1.9,3.8,-.22,.5,5.4,.22], 'metal'); if (name === 'pick') add([-2.2,4.4,-.17,2.2,5,.17], 'metal'); if (name === 'knife' || name === 'spear') { // Tapered, double-sided pointed blade, not a blunt rectangular icon. const first = vertices.length, base = length - .2, tip = length + (name === 'spear' ? 2.4 : 2.2); const coords = uv('metal'); for (const z of [-.08,.08]) for (const [i, p] of [[-.5,base],[.5,base],[0,tip]].entries()) vertices.push([p[0],p[1],z,0,0,Math.sign(z),...coords[i]]); triangles.push([first,first+2,first+1],[first+3,first+4,first+5], [first,first+1,first+4],[first,first+4,first+3], [first+1,first+2,first+5],[first+1,first+5,first+4], [first+2,first,first+3],[first+2,first+3,first+5]); } const lines = [`# Original Last Hearth held ${name}; units=10/node; grip=0,0,0; atlas=hearth_person.png`]; vertices.forEach(v => lines.push(`v ${v.slice(0,3).join(' ')}`)); vertices.forEach(v => lines.push(`vt ${v[6]} ${1-v[7]}`)); triangles.forEach(t => lines.push(`f ${t.map(i => `${i+1}/${i+1}`).join(' ')}`)); return lines.join('\n') + '\n'; } // Fixed profiles form the public Lua sound-name contract (no random variants). export const SOUND_PROFILES = Object.freeze({ hearth_step_soil: [.13, 90, .75, .24], hearth_step_wood: [.12, 180, .35, .28], hearth_step_stone: [.10, 620, .50, .23], hearth_step_water: [.22, 300, .90, .20], hearth_dig_soil: [.20, 80, .90, .28], hearth_dig_wood: [.18, 210, .50, .30], hearth_dig_stone: [.20, 930, .45, .26], hearth_land: [.24, 65, .60, .34], hearth_swing_knife: [.12, 700, .95, .18], hearth_swing_axe: [.24, 260, .95, .24], hearth_swing_spear: [.18, 440, .90, .20], hearth_impact: [.16, 140, .60, .30], hearth_block: [.20, 820, .35, .24], hearth_denial: [.16, 170, .04, .16], hearth_fire: [.50, 95, .95, .13], }); export function soundWAV(name) { if (!Object.hasOwn(SOUND_PROFILES, name)) throw new Error('Unknown original sound.'); const [duration, frequency, mix, gain] = SOUND_PROFILES[name]; const count = Math.round(SAMPLE_RATE * duration), pcm = Buffer.alloc(count * 2); // FNV-like name seed and xorshift are reproducible synthesis, NOT security RNG. let state = 2166136261; for (const char of name) state = Math.imul(state ^ char.charCodeAt(0), 16777619) >>> 0; let low = 0; for (let i = 0; i < count; i++) { state ^= state << 13; state ^= state >>> 17; state ^= state << 5; const noise = (state >>> 0) / 2147483648 - 1; low += .24 * (noise - low); const t = i / SAMPLE_RATE, phase = i / (count - 1); const envelope = Math.min(1, i / 80, (count - 1 - i) / 160) * Math.exp(-4 * phase); const tone = Math.sin(2 * Math.PI * frequency * t) * .75 + Math.sin(2 * Math.PI * frequency * 1.63 * t) * .25; const air = name.startsWith('hearth_swing_') ? noise : low; const crackle = name === 'hearth_fire' && (state >>> 0) % 97 < 3 ? noise : 0; const sample = gain * envelope * ((1-mix) * tone + mix * air + crackle * .3); pcm.writeInt16LE(Math.round(Math.max(-1, Math.min(1, sample)) * 32767), i * 2); } const header = Buffer.alloc(44); header.write('RIFF'); header.writeUInt32LE(36 + pcm.length, 4); header.write('WAVEfmt ', 8); header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22); header.writeUInt32LE(SAMPLE_RATE, 24); header.writeUInt32LE(SAMPLE_RATE * 2, 28); header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34); header.write('data', 36); header.writeUInt32LE(pcm.length, 40); return Buffer.concat([header, pcm]); } // Generated sound-source inventory / minimal build-tool SBOM. The synthesized // recordings are original MIT-licensed work (repository LICENSE.txt), not audio // taken from FFmpeg. No third-party encoder code is shipped in the game. export const AUDIO_PROVENANCE = Object.freeze({ assetLicense: 'MIT', tool: '@ffmpeg/core', version: '0.12.10', toolLicense: 'GPL-2.0-or-later', repository: 'https://github.com/ffmpegwasm/ffmpeg.wasm', tarball: 'https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz', integrity: 'sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==', javascriptSHA256: 'b266ab5b952555881dd6310663986994a182acb2b7ff25cf10a25f7a37ac2b21', wasmSHA256: '9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7', }); export const SOUND_HASHES = Object.freeze({ hearth_block: '604a7761f20c998c4c32e7a6d2beabd9333802636103a78ba7d9c866e2149b20', hearth_denial: '491257912169f7fc45c2bacf59e5f8031ad786d9fed423d76fb12d668e9c3b5b', hearth_dig_soil: '2b9509045b1da605af4a9d52ecc96cfa63f088dead8239dddc603185c2d29c9e', hearth_dig_stone: '624790a3398b7708602e1f8ca1c0d4c18ff034491cb1ef9be6de4644b85b1ceb', hearth_dig_wood: '6e827a1d916f9fd2b1ce09681eb433af949388febed55ca395827bb081a7d0cd', hearth_fire: '811175440918ea29efd79e414a3391e1e0f504b3729268a3a48ef62b173c1d15', hearth_impact: 'd051cb7e57d5cf497d1d40b3995d295fc5cd150db6578632bf597f171ce91bee', hearth_land: '14dd5b1c818f74ff8007828ccd9b5addf09e5d0c2a251be5de67ef76c498631e', hearth_step_soil: '110726200d18155c7a08c6981121a2e63b75227061ded7a77c5641c1dc4157b0', hearth_step_stone: '333b1e92466909bdcee1214cd416983abe1a088b407f4f6a11efb7e1fddde1a8', hearth_step_water: '194ce0984bcade40b050db0a76955737c5925593a1916218e56edcf4b0a7de09', hearth_step_wood: 'ea594a486f8fbea66412521725e65e77103306def95fe6e1c8c54f0fef7a9a02', hearth_swing_axe: 'c80d750f1b14860565435d321fc898506e8781c013b5645fffbc999115359795', hearth_swing_knife: '31bc8ef920db76e333e6f6cc46293998ff15792063c73e42092b37407ecee4a2', hearth_swing_spear: 'e8db0d5239834ff1426af118ca9575b957017ba7b504baa5ba0891e66a2d03e4', }); const soundDirectory = fileURLToPath(new URL('../mods/hearth/sounds/', import.meta.url)); const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); export async function soundOGG(name) { if (!Object.hasOwn(SOUND_PROFILES, name)) throw new Error('Unknown original sound.'); const filename = await safePath(path.join(soundDirectory, name + '.ogg')); const stat = await fs.lstat(filename); if (!stat.isFile() || stat.size > 65536) throw new Error('Invalid original sound source.'); const bytes = await fs.readFile(filename); if (sha256(bytes) !== SOUND_HASHES[name]) throw new Error('Original Ogg source hash mismatch: ' + name); return bytes; } // Optional developer-only codec. Set HEARTH_FFMPEG_CORE to the absolute // dist/umd/ffmpeg-core.js from the pinned package above, installed separately // with npm --ignore-scripts. Verify BOTH files before executing any JS/WASM. // This never downloads, invokes a shell, or changes project dependencies. export async function loadVorbisCodec(filename) { if (typeof filename !== 'string' || !path.isAbsolute(filename) || path.basename(filename) !== 'ffmpeg-core.js') throw new Error('HEARTH_FFMPEG_CORE must name the absolute pinned UMD ffmpeg-core.js.'); const javascript = await fs.readFile(await safePath(filename)); const wasmBinary = await fs.readFile(await safePath(filename.replace(/\.js$/, '.wasm'))); if (sha256(javascript) !== AUDIO_PROVENANCE.javascriptSHA256 || sha256(wasmBinary) !== AUDIO_PROVENANCE.wasmSHA256) throw new Error('Pinned audio toolchain hash mismatch.'); // The upstream web-worker bundle needs only location during initialization. const oldSelf = globalThis.self; let core; try { globalThis.self = {location: {href: 'file:///ffmpeg-core.js'}}; core = await createRequire(import.meta.url)(filename)({wasmBinary}); } finally { if (oldSelf === undefined) delete globalThis.self; else globalThis.self = oldSelf; } const messages = []; core.setLogger(({message}) => { // This pinned Emscripten wrapper logs exactly Aborted() for exit(0) too. // Check exec's exit status AND all actual FFmpeg error-level diagnostics. if (message !== 'Aborted()') messages.push(message); if (messages.length > 200) messages.shift(); }); function convert(bytes, input, output, options) { messages.length = 0; core.FS.writeFile(input, bytes); try { const code = core.exec('-hide_banner', '-loglevel', 'error', '-nostdin', '-y', '-xerror', '-err_detect', 'explode', '-i', input, ...options, output); if (code !== 0 || messages.length) throw new Error('Audio conversion failed: ' + messages.join('\n')); return Buffer.from(core.FS.readFile(output)); } finally { core.reset(); for (const file of [input, output]) try { core.FS.unlink(file); } catch { /* absent after failure */ } } } return { encode: bytes => convert(bytes, 'source.wav', 'sound.ogg', ['-map_metadata', '-1', '-ac', '1', '-ar', String(SAMPLE_RATE), '-c:a', 'libvorbis', '-q:a', '3', '-fflags', '+bitexact', '-flags:a', '+bitexact', '-f', 'ogg']), decode: bytes => convert(bytes, 'sound.ogg', 'decoded.pcm', ['-map', '0:a:0', '-c:a', 'pcm_s16le', '-f', 's16le']), }; } async function encodeSounds() { const codec = await loadVorbisCodec(process.env.HEARTH_FFMPEG_CORE); await fs.mkdir(await safePath(soundDirectory), {recursive: true}); const hashes = {}; for (const name of Object.keys(SOUND_PROFILES).sort()) { const bytes = codec.encode(soundWAV(name)); if (!bytes.equals(codec.encode(soundWAV(name)))) throw new Error('Non-deterministic Vorbis encoding.'); hashes[name] = sha256(bytes); if (SOUND_HASHES[name] && hashes[name] !== SOUND_HASHES[name]) throw new Error('Regeneration hash mismatch: ' + name); await fs.writeFile(await safePath(path.join(soundDirectory, name + '.ogg')), bytes, {mode: 0o600}); } console.log(JSON.stringify(hashes, null, 2)); } async function verifyEngineSounds() { // Headless discovery is NOT client playback. Never open a persistent world. const executable = process.env.HEARTH_ENGINE; if (!executable || !path.isAbsolute(executable)) throw new Error('HEARTH_ENGINE must be an absolute native Luanti executable.'); const version = spawnSync(await safePath(executable), ['--version'], {encoding: 'utf8', timeout: 10000, windowsHide: true}); if (version.status !== 0 || !/^Luanti 5\.18\.0(?=\s|$)/m.test(version.stdout)) throw new Error('Expected native Luanti 5.18.0: ' + JSON.stringify({status:version.status,stdout:version.stdout,stderr:version.stderr})); const root = await fs.mkdtemp(path.join(os.tmpdir(), 'hearth-ogg-native-')); try { const world = path.join(root, 'world'), game = path.join(world, 'game'); const { packageGame } = await import('./package.mjs'); await fs.mkdir(world); await packageGame(game); const probe = path.join(world, 'worldmods/hearth_audio_probe'); await fs.mkdir(probe, {recursive:true}); await fs.writeFile(path.join(probe, 'mod.conf'), 'name = hearth_audio_probe\ndepends = hearth\n'); await fs.writeFile(path.join(probe, 'init.lua'), [ 'core.after(0.1, function()', ' core.log("action", "LAST_HEARTH_AUDIO_DISCOVERY_PASS")', ' core.request_shutdown("audio discovery complete", false, 0)', 'end)', '', ].join('\n')); await fs.writeFile(path.join(world, 'world.mt'), 'gameid = hearth_audio_probe\nbackend = sqlite3\nplayer_backend = sqlite3\nauth_backend = sqlite3\nmod_storage_backend = sqlite3\n'); const config = path.join(root, 'minetest.conf'); await fs.writeFile(config, 'hearth_environment = staging\nhearth_latitude = 45\nhearth_longitude = 0\nbind_address = 127.0.0.1\nserver_announce = false\nmax_users = 0\n'); const result = spawnSync(executable, ['--server', '--info', '--verbose', '--world', world, '--config', config, '--port', String(randomInt(49152,65536)), '--logfile', path.join(root, 'engine.log')], { cwd:root, encoding:'utf8', timeout:90000, maxBuffer:8*1024*1024, windowsHide:true, env:{...process.env, LUANTI_GAME_PATH:root, MINETEST_GAME_PATH:''}, }); const log = result.stdout + '\n' + result.stderr; const rejected = log.split(/\r?\n/).filter(line=>/ignor|unsupported|disallowed|failed|error/i.test(line) && /hearth_.*\.(?:ogg|wav)/i.test(line)); const accepted = Object.keys(SOUND_PROFILES).filter(name=>new RegExp('Server: [a-f0-9]+ is '+name+'\\.ogg \\(').test(log)); if (result.status !== 0 || !log.includes('LAST_HEARTH_AUDIO_DISCOVERY_PASS') || rejected.length || accepted.length !== 15) throw new Error('Native audio discovery failed: ' + JSON.stringify({status:result.status,error:result.error?.message,accepted,rejected,tail:log.slice(-8000)})); console.log(JSON.stringify({engine:'Luanti 5.18.0',headless:true,acceptedOgg:accepted.length,rejectedSounds:rejected.length, playbackVerified:false,marker:'LAST_HEARTH_AUDIO_DISCOVERY_PASS'})); } finally { await fs.rm(root, {recursive:true,force:true}); } } // Reproduce sources or verify discovery only on explicit developer commands. // Normal packaging/imports are read-only and need neither codec nor engine. if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { // Do not await at module scope: packageGame imports assets -> media too. const action = process.argv[2] === '--encode-sounds' ? encodeSounds : process.argv[2] === '--verify-engine-sounds' ? verifyEngineSounds : null; if (!action) throw new Error('Expected --encode-sounds or --verify-engine-sounds.'); action().catch(error => { console.error(error); process.exitCode = 1; }); }