487 lines
29 KiB
JavaScript
Executable File
487 lines
29 KiB
JavaScript
Executable File
// 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.length); used.add(id); });
|
|
const [a,b,c] = triangle.map(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<totals.length); assert.equal(weight,1,'rigid weighting'); totals[id]+=weight;
|
|
}
|
|
assert.ok(totals.every(n => 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]))<widths.at(-1),'waist taper');
|
|
}
|
|
assert.ok(widths.length===CHARACTER_OPTIONS.bodies.length && widths.every((value,i)=>i===0||widths[i-1]<value));
|
|
assert.ok(Math.max(...balanced.bones.get('Head').weights.map(([id])=>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+4<comments.length);
|
|
const count = comments.readUInt32LE(cursor); cursor+=4;
|
|
assert.ok(count<=16);
|
|
for (let i=0;i<count;i++) {
|
|
assert.ok(cursor+4<comments.length);
|
|
const length = comments.readUInt32LE(cursor); cursor+=4+length;
|
|
assert.ok(cursor<comments.length);
|
|
}
|
|
assert.equal(cursor+1,comments.length); assert.equal(comments[cursor],1);
|
|
assert.ok(packets.slice(3).every(p=>p.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/);
|
|
});
|