// Asset-only checks: no engine launch and no persistent worlds. import test from 'node:test'; import assert from 'node:assert/strict'; import * as fs from 'node:fs/promises'; import { realpath } from 'node:fs'; import { promisify } from 'node:util'; import os from 'node:os'; import path from 'node:path'; import { inflateSync } from 'node:zlib'; import { generateAssets } from '../scripts/assets.mjs'; import { CHARACTER_ART_MANIFEST, CHARACTER_OPTIONS, characterLayer, characterMesh, compositeCharacter } from '../scripts/character-art.mjs'; import { FAUNA_ART_MANIFEST } from '../scripts/fauna-art.mjs'; import { ANIMATIONS, ATLAS, PLAYER_HEIGHT, playerB3D, SAMPLE_RATE, SOUND_HASHES, SOUND_PROFILES, soundWAV, soundOGG, loadVorbisCodec, TOOL_NAMES, toolOBJ } from '../scripts/media.mjs'; import { inventory } from '../scripts/files.mjs'; import { packageGame, SOURCE, verifyPackage } from '../scripts/package.mjs'; async function temporary(t) { const root = await fs.mkdtemp(path.join(await promisify(realpath)(os.tmpdir()), 'hearth-assets-')); t.after(() => fs.rm(root, {recursive: true, force: true})); return root; } async function sourceTree(root) { const source = path.join(root, 'source'); await fs.mkdir(path.join(source, 'mods/hearth/nested'), {recursive: true}); await fs.mkdir(path.join(source, 'mods/another'), {recursive: true}); await fs.mkdir(path.join(source, 'scripts')); for (const [file, text] of Object.entries({ 'mods/hearth/players.lua': '-- skin #A1B2C3', 'mods/hearth/nested/feedback.lua': '-- new feedback #01abef', 'mods/another/init.lua': '-- another mod #39D1A2', 'scripts/ignored.lua': '-- not mod source #fedcba', 'mods/hearth/nested/ignored.txt': '#123abc', })) await fs.writeFile(path.join(source, file), text); return source; } // Independent bounded reader: never trust the serializer's own chunk helpers. function parseB3D(bytes) { let vertexData, triangleData, animation; const bones = new Map(), chunks = []; function cstring(offset, end) { const zero = bytes.indexOf(0, offset); assert.ok(zero >= offset && zero < end, 'unterminated chunk string'); return [bytes.toString('ascii', offset, zero), zero+1]; } function floats(offset, count, end) { assert.ok(offset + count*4 <= end, 'float record escapes chunk'); const result = Array.from({length: count}, (_, i) => bytes.readFloatLE(offset+i*4)); assert.ok(result.every(Number.isFinite)); return result; } function walk(start, end, parent = null) { let offset = start; while (offset < end) { assert.ok(offset+8 <= end, 'truncated chunk header'); const tag = bytes.toString('ascii', offset, offset+4), length = bytes.readInt32LE(offset+4); const data = offset+8, limit = data+length; assert.ok(length >= 0 && limit <= end, `${tag} escapes parent bounds`); chunks.push(tag); if (tag === 'BB3D') { assert.equal(bytes.readInt32LE(data), 1); walk(data+4, limit); } else if (tag === 'NODE') { const [name, next] = cstring(data, limit); assert.ok(!bones.has(name)); const transform = floats(next, 10, limit); bones.set(name, {parent, position: transform.slice(0,3), transform, weights: [], keys: []}); walk(next+40, limit, name); } else if (tag === 'MESH') { assert.equal(bytes.readInt32LE(data), 0); walk(data+4, limit, parent); } else if (tag === 'VRTS') { assert.deepEqual([0,4,8].map(n => bytes.readInt32LE(data+n)), [1,1,2]); assert.equal((length-12)%32, 0); vertexData = Array.from({length: (length-12)/32}, (_, i) => floats(data+12+i*32, 8, limit)); } else if (tag === 'TRIS') { assert.equal(bytes.readInt32LE(data), 0); assert.equal((length-4)%12, 0); triangleData = Array.from({length: (length-4)/12}, (_, i) => [0,4,8].map(n => bytes.readInt32LE(data+4+i*12+n))); } else if (tag === 'BONE') { assert.equal(length%8, 0); for (let p = data; p < limit; p += 8) bones.get(parent).weights.push([bytes.readInt32LE(p), ...floats(p+4,1,limit)]); } else if (tag === 'KEYS') { assert.equal(bytes.readInt32LE(data), 5); assert.equal((length-4)%32, 0); for (let p = data+4; p < limit; p += 32) bones.get(parent).keys.push({ frame: bytes.readInt32LE(p)-1, position: floats(p+4,3,limit), rotation: floats(p+16,4,limit), }); } else if (tag === 'ANIM') { assert.equal(length, 12); animation = [bytes.readInt32LE(data), bytes.readInt32LE(data+4), ...floats(data+8,1,limit)]; } else if (tag === 'TEXS') { const [texture, next] = cstring(data, limit); assert.equal(texture, 'hearth_person.png'); assert.equal(next+28, limit); assert.deepEqual([bytes.readInt32LE(next),bytes.readInt32LE(next+4)], [1,2]); assert.deepEqual(floats(next+8,5,limit), [0,0,1,1,0]); } else if (tag === 'BRUS') { assert.equal(bytes.readInt32LE(data), 1); const [, next] = cstring(data+4, limit); assert.equal(next+32, limit); assert.deepEqual(floats(next,5,limit), [1,1,1,1,0]); assert.deepEqual([20,24,28].map(n => bytes.readInt32LE(next+n)), [1,0,0]); } else assert.fail(`unexpected chunk ${tag}`); offset = limit; } assert.equal(offset, end); } assert.equal(bytes.toString('ascii',0,4), 'BB3D'); assert.equal(bytes.readInt32LE(4), bytes.length-8); walk(0, bytes.length); return {vertices: vertexData, triangles: triangleData, bones, animation, chunks}; } function crc32(bytes) { let value = 0xffffffff; for (const b of bytes) { value ^= b; for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0); } return (value ^ 0xffffffff) >>> 0; } function parsePNG(bytes) { assert.deepEqual([...bytes.subarray(0,8)], [137,80,78,71,13,10,26,10]); let width, height, offset = 8, ended = false; const compressed = []; while (offset < bytes.length) { assert.ok(offset+12 <= bytes.length); const size = bytes.readUInt32BE(offset), end = offset+8+size; assert.ok(end+4 <= bytes.length); const tag = bytes.toString('ascii',offset+4,offset+8), data = bytes.subarray(offset+8,end); assert.equal(crc32(bytes.subarray(offset+4,end)), bytes.readUInt32BE(end)); if (tag === 'IHDR') { assert.equal(size,13); width = data.readUInt32BE(0); height = data.readUInt32BE(4); assert.deepEqual([...data.subarray(8)], [8,6,0,0,0]); } else if (tag === 'IDAT') compressed.push(data); else { assert.equal(tag,'IEND'); assert.equal(size,0); ended = true; } offset = end+4; } assert.ok(ended); assert.equal(offset,bytes.length); const raw = inflateSync(Buffer.concat(compressed), {maxOutputLength: 1024*1024}); assert.equal(raw.length,height*(width*4+1)); for (let y = 0; y < height; y++) assert.equal(raw[y*(width*4+1)],0); return {width,height,pixel: (x,y) => [...raw.subarray(y*(width*4+1)+1+x*4,y*(width*4+1)+5+x*4)]}; } for (const profile of CHARACTER_OPTIONS.bodies) test(`B3D ${profile} has bounded chunks, one textured mesh, valid triangles/normals/UVs and normalized bone weights`, () => { const model = parseB3D(playerB3D(profile)); assert.deepEqual(model.animation,[0,100,24]); assert.equal(model.chunks.filter(c => c==='MESH').length,1); assert.equal(model.vertices.length,384); assert.equal(model.triangles.length,192); assert.equal(Math.max(...model.vertices.map(v => v[1])),PLAYER_HEIGHT); assert.equal(Math.min(...model.vertices.map(v => v[1])),0); assert.equal(PLAYER_HEIGHT/10,1.75); const totals = Array(model.vertices.length).fill(0), used = new Set(); for (const triangle of model.triangles) { assert.equal(new Set(triangle).size,3); triangle.forEach(id => { assert.ok(id>=0 && id model.vertices[id]); const u = b.slice(0,3).map((v,i)=>v-a[i]), v = c.slice(0,3).map((v,i)=>v-a[i]); const normal = [u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]]; assert.ok(normal.reduce((sum,n,i)=>sum+n*a[3+i],0)>0,'outward winding'); } assert.equal(used.size,model.vertices.length); for (const vertex of model.vertices) { assert.ok(Math.abs(Math.hypot(...vertex.slice(3,6))-1)<1e-6); assert.ok(vertex.slice(6).every(n => n>0 && n<1)); } for (const bone of model.bones.values()) for (const [id, weight] of bone.weights) { assert.ok(id>=0 && id n===1)); assert.equal(model.bones.get('Hand_Right').parent,'Arm_Right'); assert.equal(model.bones.get('Hand_Left').parent,'Arm_Left'); assert.ok(model.bones.get('Hand_Right').weights.length>0); }); test('all body profiles share exact rig, rigid assignments, UVs and 100 keys; only torso and limb widths change', () => { const balanced = parseB3D(playerB3D()), widths=[]; assert.deepEqual(playerB3D(),playerB3D('balanced')); assert.deepEqual([...balanced.bones].map(([name,b])=>[name,b.parent,b.position]),[ ['Root',null,[0,0,0]],['Body','Root',[0,8,0]],['Head','Body',[0,5.5,0]], ['Arm_Right','Body',[-Math.fround(3.1),5,0]],['Hand_Right','Arm_Right',[0,-Math.fround(4.8),0]], ['Arm_Left','Body',[Math.fround(3.1),5,0]],['Hand_Left','Arm_Left',[0,-Math.fround(4.8),0]], ['Leg_Right','Root',[-Math.fround(1.15),8,0]],['Leg_Left','Root',[Math.fround(1.15),8,0]], ]); const headTiles=['hair','face','skin','hair','side','side']; for (let face=0;face<6;face++) for (let corner=0;corner<4;corner++) { const vertex=balanced.vertices[24+face*4+corner]; assert.deepEqual(vertex.slice(6).map(n=>Math.floor(n*8)),ATLAS[headTiles[face]],'unchanged semantic head UVs'); } for (const profile of CHARACTER_OPTIONS.bodies) { const model=parseB3D(playerB3D(profile)); assert.deepEqual(model.bones,balanced.bones,'bind transforms, hands, weights and all animation keys'); assert.deepEqual(model.triangles,balanced.triangles); assert.deepEqual(model.vertices.map(v=>v.slice(6)),balanced.vertices.map(v=>v.slice(6))); assert.deepEqual(model.vertices.map(v=>v[1]),balanced.vertices.map(v=>v[1])); for (const name of ['Head','Hand_Right','Hand_Left']) for (const [id] of model.bones.get(name).weights) assert.deepEqual(model.vertices[id],balanced.vertices[id],name+' remains exactly aligned'); const torso=model.bones.get('Body').weights.map(([id])=>model.vertices[id]); widths.push(Math.max(...torso.map(v=>v[0]))); assert.ok(Math.max(...torso.filter(v=>v[1]===8).map(v=>v[0]))i===0||widths[i-1]balanced.vertices[id][0]))<=1.6); assert.ok(Math.max(...balanced.bones.get('Head').weights.map(([id])=>balanced.vertices[id][2]))>1.5, 'added nose and ears stay within the existing broad head collision envelope'); for (const bad of ['toString','../broad','BALANCED',null,{},0]) assert.throws(()=>playerB3D(bad)); }); test('B3D keys use engine frames 0..99, all six clips move weighted bones, loops close, and root motion is absent', () => { const {bones} = parseB3D(playerB3D()); assert.deepEqual(ANIMATIONS, {idle:[0,19,12],walk:[20,39,24],run:[40,59,32],strike:[60,69,28],guard:[70,79,12],swim:[80,99,20]}); for (const bone of bones.values()) { assert.equal(bone.keys.length,100); bone.keys.forEach((key,i) => { assert.equal(key.frame,i); assert.ok(Math.abs(Math.hypot(...key.rotation)-1)<1e-6); }); } for (const [clip,[start,end]] of Object.entries(ANIMATIONS)) { assert.ok([...bones.values()].some(b => b.weights.length && b.keys.slice(start+1,end).some(k => JSON.stringify(k.rotation)!==JSON.stringify(b.keys[start].rotation) || k.position.some((v,i)=>Math.abs(v-b.keys[start].position[i])>1e-5))),`${clip} must animate`); if (clip!=='strike') for (const bone of bones.values()) { [...bone.keys[start].position,...bone.keys[start].rotation].forEach((n,i) => assert.ok(Math.abs(n-[...bone.keys[end].position,...bone.keys[end].rotation][i])<1e-5,`${clip} seam`)); } } assert.ok(bones.get('Root').keys.every(k => k.position.every(n=>n===0))); const arm = bones.get('Arm_Right'); assert.ok(Math.abs(arm.keys[64].rotation[1])>.7,'strike is substantial, not a static placeholder'); assert.ok(Math.abs(arm.keys[70].rotation[1])>.4,'guard raises the arm'); assert.deepEqual(playerB3D(),playerB3D()); }); // Independent Ogg page/packet reader: verify framing, lacing, CRC, sequence, // Vorbis headers, granule length and EOS; magic bytes alone cannot pass. function parseOgg(bytes) { assert.ok(bytes.length > 100 && bytes.length <= 65536); let offset = 0, sequence = 0, serial, ended = false, granule = 0n; const packets = [], pieces = []; while (offset < bytes.length) { assert.ok(!ended && offset + 27 <= bytes.length, 'page after EOS or truncated header'); assert.equal(bytes.toString('ascii',offset,offset+4),'OggS'); assert.equal(bytes[offset+4],0); const flags = bytes[offset+5], segments = bytes[offset+26]; assert.equal(flags & ~7,0); assert.equal(Boolean(flags & 1),pieces.length>0,'packet continuation'); assert.equal(Boolean(flags & 2),sequence===0,'BOS only on first page'); assert.ok(offset+27+segments<=bytes.length); const lacing = bytes.subarray(offset+27,offset+27+segments); const end = offset+27+segments+lacing.reduce((a,b)=>a+b,0); assert.ok(end<=bytes.length,'page body within bounds'); const page = Buffer.from(bytes.subarray(offset,end)), storedCRC = page.readUInt32LE(22); page.writeUInt32LE(0,22); let crc = 0; for (const byte of page) { crc ^= byte << 24; for (let bit=0;bit<8;bit++) crc = ((crc<<1) ^ ((crc & 0x80000000) ? 0x04c11db7 : 0)) >>> 0; } assert.equal(crc,storedCRC,'Ogg page CRC'); serial ??= bytes.readUInt32LE(offset+14); assert.equal(bytes.readUInt32LE(offset+14),serial,'single logical stream'); assert.equal(bytes.readUInt32LE(offset+18),sequence++,'contiguous page sequence'); const position = bytes.readBigUInt64LE(offset+6); if (position !== 0xffffffffffffffffn) { assert.ok(position>=granule); granule=position; } let start = offset+27+segments; for (const size of lacing) { pieces.push(bytes.subarray(start,start+size)); start+=size; if (size<255) { packets.push(Buffer.concat(pieces)); pieces.length=0; } } ended = Boolean(flags & 4); offset=end; } assert.ok(ended && pieces.length===0 && packets.length>3,'complete stream with audio packets'); for (const [i,type] of [1,3,5].entries()) { assert.equal(packets[i][0],type); assert.equal(packets[i].toString('ascii',1,7),'vorbis'); } const identification = packets[0]; assert.equal(identification.length,30); assert.equal(identification.readUInt32LE(7),0); assert.equal(identification[11],1); assert.equal(identification.readUInt32LE(12),SAMPLE_RATE); assert.equal(identification[29],1); const small = identification[28] & 15, large = identification[28] >>> 4; assert.ok(small>=6 && large>=small && large<=13); const comments = packets[1], vendorLength = comments.readUInt32LE(7); let cursor = 11+vendorLength; assert.ok(cursor+4p.length>0 && (p[0] & 1)===0),'Vorbis audio packets'); return {granule:Number(granule),blockSize:2**large}; } test('original Ogg Vorbis sources are hash-pinned, complete mono 22050Hz streams; synthesis and optional full decode stay bounded', async t => { assert.equal(Object.keys(SOUND_PROFILES).length,15); assert.deepEqual(Object.keys(SOUND_HASHES).sort(),Object.keys(SOUND_PROFILES).sort()); const codec = process.env.HEARTH_FFMPEG_CORE ? await loadVorbisCodec(process.env.HEARTH_FFMPEG_CORE) : null; if (!codec) t.diagnostic('Codec decode/regeneration NOT run: set HEARTH_FFMPEG_CORE to the pinned optional tool. Ogg framing/CRC/hash checks still run.'); const unique = new Set(); for (const [name,[duration]] of Object.entries(SOUND_PROFILES)) { assert.match(name,/^[a-z0-9_]+$/,'engine-supported unnumbered sound name'); const ogg = await soundOGG(name), {granule,blockSize} = parseOgg(ogg); assert.deepEqual(ogg,await soundOGG(name)); unique.add(ogg.toString('base64')); const bytes = soundWAV(name), count = Math.round(duration*SAMPLE_RATE); assert.equal(granule,count,'EOS granule equals original duration'); if (codec) { assert.deepEqual(codec.encode(bytes),ogg,'pinned regeneration is byte-identical'); const pcm = codec.decode(ogg); assert.equal(pcm.length%2,0); // Vorbis overlap/padding differs from source PCM; EOS is checked exactly // above. Decode the entire stream, not just its identification header. assert.ok(Math.abs(pcm.length/2-count)<=blockSize,'bounded decoded overlap/padding'); const decoded = Array.from({length:pcm.length/2},(_,i)=>pcm.readInt16LE(i*2)); assert.ok(Math.max(...decoded.map(Math.abs))>300,'decoded audio is non-silent'); assert.ok(decoded.every(n=>Math.abs(n)<32767),'no decoded clipping'); assert.ok(Math.abs(decoded.reduce((a,b)=>a+b,0)/decoded.length)<150,'decoded DC offset'); } assert.deepEqual(bytes,soundWAV(name)); assert.equal(bytes.toString('ascii',0,4),'RIFF'); assert.equal(bytes.readUInt32LE(4),bytes.length-8); assert.equal(bytes.toString('ascii',8,16),'WAVEfmt '); assert.equal(bytes.readUInt32LE(16),16); assert.equal(bytes.readUInt16LE(20),1); assert.equal(bytes.readUInt16LE(22),1); assert.equal(bytes.readUInt32LE(24),22050); assert.equal(bytes.readUInt32LE(28),44100); assert.equal(bytes.readUInt16LE(32),2); assert.equal(bytes.readUInt16LE(34),16); assert.equal(bytes.toString('ascii',36,40),'data'); assert.equal(bytes.readUInt32LE(40),count*2); assert.equal(bytes.length,44+count*2); assert.ok(duration>=.08 && duration<=.5); const samples = Array.from({length:count},(_,i)=>bytes.readInt16LE(44+i*2)); assert.equal(samples[0],0); assert.equal(samples.at(-1),0); assert.ok(Math.max(...samples.map(Math.abs))>300); assert.ok(samples.every(n=>Math.abs(n)<32767)); assert.ok(Math.abs(samples.reduce((a,b)=>a+b,0)/count)<150,'bounded DC offset'); } assert.equal(unique.size,15); assert.throws(()=>soundWAV('../anything')); assert.throws(()=>soundWAV('toString')); await assert.rejects(soundOGG('../anything')); await assert.rejects(soundOGG('toString')); const valid = await soundOGG('hearth_block'); assert.throws(()=>parseOgg(valid.subarray(0,valid.length-1)),'truncation rejected'); const corrupt = Buffer.from(valid); corrupt[corrupt.length-1]^=1; assert.throws(()=>parseOgg(corrupt),/CRC/); assert.throws(()=>parseOgg(soundWAV('hearth_block')),'renamed WAV rejected'); if (codec) { assert.throws(()=>codec.decode(valid.subarray(0,80)),/conversion failed/,'decoder rejects incomplete setup'); t.diagnostic('All 15 sounds fully decoded and regenerated byte-identically with hash-verified @ffmpeg/core@0.12.10.'); } }); test('held tool meshes have deterministic indexed geometry, grip origins and atlas UVs', () => { const unique = new Set(); for (const name of TOOL_NAMES) { const obj = toolOBJ(name); assert.equal(obj,toolOBJ(name)); unique.add(obj); const vertices = [], coords = [], faces = []; for (const line of obj.split('\n')) { if (line.startsWith('v ')) vertices.push(line.slice(2).split(' ').map(Number)); if (line.startsWith('vt ')) coords.push(line.slice(3).split(' ').map(Number)); if (line.startsWith('f ')) faces.push(line.slice(2).split(' ')); } assert.ok(vertices.length>=48); assert.equal(coords.length,vertices.length); assert.ok(vertices.flat().every(Number.isFinite)); assert.ok(coords.flat().every(n=>Number.isFinite(n) && n>0 && n<1)); assert.equal(Math.min(...vertices.map(v=>v[1])),-1); assert.ok(Math.max(...vertices.map(v=>v[1]))>3); for (const face of faces) { assert.equal(face.length,3); for (const item of face) for (const id of item.split('/').map(Number)) assert.ok(id>=1 && id<=vertices.length); } } assert.equal(unique.size,4); assert.throws(()=>toolOBJ('../knife')); }); test('all mod Lua colors are scanned recursively; scripts and non-Lua are not inputs; PNG icons retain crisp alpha and metal/handle colors', async t => { const root = await temporary(t), source = await sourceTree(root), destination = path.join(root,'output'); await generateAssets(destination,source); const textures = path.join(destination,'mods/hearth/textures'), names = await fs.readdir(textures); for (const color of ['a1b2c3','01abef','39d1a2']) assert.ok(names.includes(`hearth_${color}.png`)); for (const color of ['fedcba','123abc']) assert.ok(!names.includes(`hearth_${color}.png`)); for (const name of TOOL_NAMES) { const image = parsePNG(await fs.readFile(path.join(textures,`hearth_icon_${name}.png`))); assert.equal(image.width,32); assert.equal(image.height,32); let opaque = 0, metal = 0, wood = 0; for (let y=0;y<32;y++) for (let x=0;x<32;x++) { const [r,g,b,a] = image.pixel(x,y); assert.ok(a===0 || a===255); if (a) { opaque++; if (g>r && b>r) metal++; if (r>g && g>b) wood++; } else assert.deepEqual([r,g,b],[0,0,0]); } assert.ok(opaque>35 && opaque<400); assert.ok(metal>5); assert.ok(wood>5); assert.equal(image.pixel(0,0)[3],0); assert.equal(image.pixel(31,31)[3],0); } const skin = parsePNG(await fs.readFile(path.join(textures,'hearth_person.png'))); assert.equal(skin.width,64); assert.equal(skin.height,64); assert.ok(skin.pixel(2,3)[0]<60,'visible eyes'); assert.ok(skin.pixel(3,4)[0]>150,'skin'); assert.notDeepEqual(skin.pixel(2,3),skin.pixel(3,4)); const tunic = ATLAS.tunic.map(n=>n*8+3); assert.ok(skin.pixel(...tunic)[1]>skin.pixel(...tunic)[0],'distinct clothes'); for (let y=0;y<64;y++) for (let x=0;x<64;x++) assert.equal(skin.pixel(x,y)[3],255); const expected=compositeCharacter(); for (let y=0;y<64;y++) for (let x=0;x<64;x++) assert.deepEqual(skin.pixel(x,y),[...expected.data.subarray((y*64+x)*4,(y*64+x+1)*4)]); const manifest=JSON.parse(await fs.readFile(path.join(destination,'character-art.json'),'utf8')); assert.deepEqual(manifest.images,CHARACTER_ART_MANIFEST); assert.deepEqual(manifest.options,CHARACTER_OPTIONS); assert.deepEqual(manifest.meshes,CHARACTER_OPTIONS.bodies.map(body=>({body,filename:characterMesh(body)}))); assert.equal(names.filter(n=>n.startsWith('hearth_char_')).length,CHARACTER_ART_MANIFEST.length); for (const {filename} of CHARACTER_ART_MANIFEST) { const decoded=parsePNG(await fs.readFile(path.join(textures,filename))), layer=characterLayer(filename); assert.equal(decoded.width,64); assert.equal(decoded.height,64); for (let y=0;y<64;y++) for (let x=0;x<64;x++) assert.deepEqual(decoded.pixel(x,y),[...layer.data.subarray((y*64+x)*4,(y*64+x+1)*4)],filename); } const portraits=parsePNG(await fs.readFile(path.join(textures,'hearth_character_contact_sheet.png'))); assert.equal(portraits.width,512); assert.equal(portraits.height,64); for (const body of CHARACTER_OPTIONS.bodies) assert.deepEqual(await fs.readFile(path.join(destination,'mods/hearth/models',characterMesh(body))),playerB3D(body)); }); test('real-source assets regenerate byte-identically including B3D, tools, textures and sounds', async t => { const root = await temporary(t), a = path.join(root,'a'), b = path.join(root,'b'); await generateAssets(a,SOURCE); await generateAssets(b,SOURCE); const first = await inventory(a); assert.deepEqual(first,await inventory(b)); await fs.writeFile(path.join(a,'mods/hearth/sounds/hearth_block.wav'),soundWAV('hearth_block')); await generateAssets(a,SOURCE); assert.deepEqual(first,await inventory(a)); for (const name of ['models/hearth_person.b3d','textures/hearth_person.png', ...TOOL_NAMES.map(n=>`models/hearth_${n}.obj`),...Object.keys(SOUND_PROFILES).map(n=>`sounds/${n}.ogg`)]) { assert.ok(first.some(entry=>entry.path==='mods/hearth/'+name && entry.size>0),name); } }); test('original Ogg media is part of the verified package manifest without a runtime encoder or WAV leftovers', async t => { const root = await temporary(t), source = await sourceTree(root); await fs.mkdir(path.join(source,'tests/fixtures'),{recursive:true}); for (const name of ['game.conf','minetest.conf','settingtypes.txt','LICENSE.txt','release.json','tests/fixtures/scenarios.json']) await fs.copyFile(path.join(SOURCE,name),path.join(source,name)); for (const name of ['init','model','runtime','content','encounters']) await fs.writeFile(path.join(source,'mods/hearth',name+'.lua'),'-- fixture\n'); const destination = path.join(root,'package'); await packageGame(destination,{source}); const {entries} = await verifyPackage(destination,{requireBundled:false}); for (const name of Object.keys(SOUND_PROFILES)) { const entry = entries.find(e=>e.path===`mods/hearth/sounds/${name}.ogg`); assert.ok(entry,name); assert.equal(entry.sha256,SOUND_HASHES[name]); parseOgg(await fs.readFile(path.join(destination,entry.path))); } assert.ok(!entries.some(e=>e.path.endsWith('.wav'))); assert.ok(entries.some(e=>e.path==='mods/hearth/models/hearth_person.b3d')); assert.ok(entries.some(e=>e.path==='mods/hearth/textures/hearth_person.png')); assert.ok(entries.some(e=>e.path==='character-art.json')); for (const {filename} of CHARACTER_ART_MANIFEST) assert.ok(entries.some(e=>e.path===`mods/hearth/textures/${filename}`),filename); for (const {id,mesh,texture} of FAUNA_ART_MANIFEST) { assert.ok(entries.some(e=>e.path===`mods/hearth/models/${mesh}`),`${id} model`); assert.ok(entries.some(e=>e.path===`mods/hearth/textures/${texture}`),`${id} texture`); } assert.ok(entries.some(e=>e.path==='fauna-art.json')); for (const body of CHARACTER_OPTIONS.bodies) assert.ok(entries.some(e=>e.path===`mods/hearth/models/${characterMesh(body)}`)); }); test('asset walker refuses source directory junctions/symlinks without following or writing outside', async t => { const root = await temporary(t), source = await sourceTree(root), outside = path.join(root,'outside'); await fs.mkdir(outside); await fs.writeFile(path.join(outside,'secret.lua'),'#c0ffee'); await fs.symlink(outside,path.join(source,'mods/linked'),process.platform==='win32'?'junction':'dir'); await assert.rejects(generateAssets(path.join(root,'output'),source),/symbolic link|junction/); assert.deepEqual(await fs.readdir(outside),['secret.lua']); await assert.rejects(fs.stat(path.join(root,'output')), {code:'ENOENT'}); }); test('asset writer refuses linked output directories and files', async t => { const root = await temporary(t), source = await sourceTree(root), outside = path.join(root,'outside'); const destination = path.join(root,'output'), textures = path.join(destination,'mods/hearth/textures'); await fs.mkdir(outside); await fs.writeFile(path.join(outside,'keep'),'unchanged'); await fs.mkdir(path.dirname(textures),{recursive:true}); await fs.symlink(outside,textures,process.platform==='win32'?'junction':'dir'); await assert.rejects(generateAssets(destination,source),/symbolic link|junction/); assert.deepEqual(await fs.readdir(outside),['keep']); await fs.unlink(textures); await fs.mkdir(textures); try { await fs.symlink(path.join(outside,'keep'),path.join(textures,'hearth_01abef.png'),'file'); } catch (error) { if (['EPERM','EACCES'].includes(error.code)) { t.diagnostic('File symlink privilege unavailable; directory junction protection verified.'); return; } throw error; } await assert.rejects(generateAssets(destination,source),/symbolic link|junction/); assert.equal(await fs.readFile(path.join(outside,'keep'),'utf8'),'unchanged'); }); test('asset source Lua has bounded read size', async t => { const root = await temporary(t), source = await sourceTree(root); await fs.writeFile(path.join(source,'mods/hearth/large.lua'),Buffer.alloc(2*1024*1024+1,32)); await assert.rejects(generateAssets(path.join(root,'output'),source),/size limit/); });