Check imported world size, buffers, materials and block codes (t80)

This commit is contained in:
2026-10-29 17:55:18 -04:00
parent cf762268ce
commit 0483fb8da4
4 changed files with 95 additions and 5 deletions
+12
View File
@@ -4,6 +4,18 @@ What agents have done, newest first. Add an entry for every piece of work: the d
note or request it was for, what changed, the files touched, the tests run, and anything left
open or not verified. Keep each entry short; the git history has the details.
## 2026-10-29: t80 validate imported world arrays (Codex)
- Validate positive integer dimensions up to the Huge option (6144 x 2048), cell/block/fog
buffers, columns and slope phases before alloc. Clean unknown materials and block codes
in existing scans using NMAT and PIECE_BY_CODE. Smaller worldgen/test worlds still load.
- Files: src/sim/save.js, test/t_load.js, test/t_fog.js, tasks/t80_imported_world_files.md
and this log. Regression failed first (40 checks); all 37 headless suites passed via
python3 test/run_all.py (/tmp/t80-headless.log; load: 60 checks). Fog's short-buffer test
now requires rejection, matching this task; missing fog still works.
- Browser/GPU tests skipped as requested; owner import/playtesting remains open.
World generation and simulation tick behaviour unchanged. No commit.
## 2026-10-29: t79 preserve progress before world import (Codex)
- importWorldFile uses GAME.running to await saveWorld('auto', true) before reading
+28 -4
View File
@@ -32,7 +32,24 @@ function serializeWorld() {
}
function deserializeWorld(meta, buffers) {
if (meta.v !== SAVE_VERSION) throw new Error('This world was saved by an older version of the game and cannot be loaded.');
alloc(meta.W, meta.H);
// worldgen accepts smaller test worlds; the largest nw-size option is 6144 x 2048.
const w = meta.W, h = meta.H;
if (!Number.isInteger(w) || !Number.isInteger(h) || w <= 0 || h <= 0 || w > 6144 || h > 2048)
throw new Error('Invalid world size: expected positive whole dimensions up to 6144 x 2048.');
const n = w * h, nb = Math.ceil(w / BS) * Math.ceil(h / BS);
for (const key of ['mat', 'temp', 'life', 'flags', 'part', 'blockP', 'blockV', 'bgm', 'fog']) {
if ((key === 'bgm' || key === 'fog') && !Object.hasOwn(buffers, key)) continue;
const expected = key === 'blockP' || key === 'blockV' ? nb : key === 'fog' ? Math.ceil(w / FOG) * Math.ceil(h / FOG) : n;
if (!buffers[key] || buffers[key].length !== expected)
throw new Error('Invalid saved buffer: ' + key + ' must contain ' + expected + ' values.');
}
for (const key of ['surfY', 'dirtY', 'graniteY', 'basaltY', 'magmaY']) {
if (!meta.cols?.[key] || meta.cols[key].length !== w)
throw new Error('Invalid saved column: ' + key + ' must contain ' + w + ' values.');
}
if (meta.slopePhases && meta.slopePhases.length !== nb)
throw new Error('Invalid saved buffer: slopePhases must contain ' + nb + ' values.');
alloc(w, h);
devWorld = meta.dev === true;
devLog = devWorld && Array.isArray(meta.devLog) ? meta.devLog.filter(e => Array.isArray(e) && Number.isSafeInteger(e[0]) && e[0] >= 0 && typeof e[1] === 'string').slice(-200).map(e => [e[0], e[1]]) : [];
worldSeed = meta.seed;
@@ -41,9 +58,16 @@ function deserializeWorld(meta, buffers) {
surfY.set(meta.cols.surfY); dirtY.set(meta.cols.dirtY); graniteY.set(meta.cols.graniteY); basaltY.set(meta.cols.basaltY); magmaY.set(meta.cols.magmaY);
computeAmbient(); fillNoise();
mat.set(buffers.mat); life.set(buffers.life); flags.set(buffers.flags);
for (let i = 0; i < N; i++) if (mat[i] === SPRING) mat[i] = STONE; // springs are gone: older worlds keep plain rock there
blockP.set(buffers.blockP); blockV.set(buffers.blockV); filtersDirty = true; rebuildProcessors();
for (let b = 0; b < NB; b++) if (blockP[b] === BP_RACK) rackState.set(b, newRack());
for (let i = 0; i < N; i++) {
if (mat[i] >= NMAT) mat[i] = EMPTY;
else if (mat[i] === SPRING) mat[i] = STONE; // springs are gone: older worlds keep plain rock there
}
blockP.set(buffers.blockP); blockV.set(buffers.blockV); filtersDirty = true;
for (let b = 0; b < NB; b++) {
if (!Object.hasOwn(PIECE_BY_CODE, blockP[b])) blockP[b] = 0;
if (blockP[b] === BP_RACK) rackState.set(b, newRack());
}
rebuildProcessors();
for (const entry of Array.isArray(meta.racks) ? meta.racks : []) {
if (!Array.isArray(entry)) continue;
const [b, q, open] = entry;
+7 -1
View File
@@ -20,7 +20,13 @@ check('save/load keeps fog', run('(()=>{fogLook(bot.x+bot.w/2,bot.y+bot.h/2);con
rect(run, 200, 160, 203, 163, run('EMPTY'));
run('placeBlock(BP_WALL, RSTONE, 50, 40, true)');
check('old save rebuilds exploration', run('(()=>{const s=serializeWorld();delete s.buffers.fog;deserializeWorld(s.meta,s.buffers);return fogSeen(62,104)>=200 && fogSeen(202,162)===255 && fogSeen(20,220)===0})()'));
check('wrong length fog rebuilds exploration', run('(()=>{const s=serializeWorld();s.buffers.fog=new Uint8Array(1);deserializeWorld(s.meta,s.buffers);return fogSeen(202,162)===255})()'));
check('wrong length fog is refused without changing exploration', run(`(() => {
const s = serializeWorld(), originalFog = fog, originalMat = mat;
s.buffers.fog = new Uint8Array(1);
try { deserializeWorld(s.meta, s.buffers); }
catch (e) { return /fog/i.test(e.message) && fog === originalFog && mat === originalMat && fogSeen(202,162) === 255; }
return false;
})()`));
const hash = () => run('mat.reduce((h,v)=>Math.imul(h^v,16777619)>>>0,2166136261)');
run('generateWorld(42, 256, 256, null)'); ticks(run, 300); const h = hash();
run('generateWorld(42, 256, 256, null); fogLook = function () {}'); ticks(run, 300);
+48
View File
@@ -1,8 +1,56 @@
// Reload wakes nearby areas first, without losing distant water, fire or saved state.
const fs = require('fs');
const path = require('path');
const { load, suite, flatWorld, rect, count, ticks } = require('./harness');
const { run } = load();
const { check, done } = suite();
// Exercise the real hover function headlessly, without starting a browser or GPU.
run("const localStorage = { getItem: () => '{}', setItem() {} };");
for (const f of ['ui/state.js', 'ui/hud.js', 'ui/cursor.js', 'ui/info.js'])
run(fs.readFileSync(path.join(__dirname, '../src', f), 'utf8'));
flatWorld(run, 256, 256, 200);
run('globalThis.validationSave = serializeWorld();');
function refusedBeforeAlloc(label, doctor, message) {
check(label, run(`(() => {
const s = { meta: { ...validationSave.meta, cols: { ...validationSave.meta.cols } }, buffers: { ...validationSave.buffers } };
${doctor}
const originalAlloc = alloc, originalMat = mat, originalBlocks = blockP;
const oldW = W, oldH = H, oldMoney = money, oldX = bot.x;
let allocations = 0, error;
alloc = () => { allocations++; throw new Error('Allocation reached'); };
try { deserializeWorld(s.meta, s.buffers); } catch (e) { error = e; }
finally { alloc = originalAlloc; }
return error instanceof Error && ${message}.test(error.message) && allocations === 0
&& W === oldW && H === oldH && mat === originalMat && blockP === originalBlocks
&& money === oldMoney && bot.x === oldX;
})()`));
}
for (const [w, h] of [[1000000, 1000000], [6145, 1024], [2048, 2049], [0, 1024], [2048, -1], [2048.5, 1024], [2048, NaN]])
refusedBeforeAlloc(`invalid size ${w} x ${h} is refused before touching the world`, `s.meta.W = ${w}; s.meta.H = ${h};`, '/world size/i');
for (const key of ['mat', 'temp', 'life', 'flags', 'part', 'blockP', 'blockV', 'bgm', 'fog']) {
refusedBeforeAlloc(`short ${key} buffer is refused before touching the world`, `s.buffers.${key} = s.buffers.${key}.subarray(1);`, `/${key}/i`);
refusedBeforeAlloc(`long ${key} buffer is refused before touching the world`, `s.buffers.${key} = new s.buffers.${key}.constructor(s.buffers.${key}.length + 1);`, `/${key}/i`);
}
for (const key of ['mat', 'temp', 'life', 'flags', 'part', 'blockP', 'blockV'])
refusedBeforeAlloc(`missing ${key} buffer is refused before touching the world`, `delete s.buffers.${key};`, `/${key}/i`);
for (const key of ['surfY', 'dirtY', 'graniteY', 'basaltY', 'magmaY'])
refusedBeforeAlloc(`short ${key} column is refused before touching the world`, `s.meta.cols.${key} = [];`, `/${key}/i`);
refusedBeforeAlloc('short slope phases are refused before touching the world', 's.meta.slopePhases = [0];', '/slopePhases/i');
for (const [label, doctor, clean] of [
['unknown cell material', 's.buffers.mat[20 * W + 20] = 250;', 'mat[20 * W + 20] === EMPTY'],
['unknown block code', 's.buffers.blockP[5 * BW + 5] = 250;', 'blockP[5 * BW + 5] === 0'],
]) {
check(label + ' loads cleaned and permits hover and removal', run(`(() => {
const s = serializeWorld(); ${doctor}
try {
deserializeWorld(s.meta, s.buffers);
SET.fog = false; player.lens = false;
return ${clean} && hoverInfo(20, 20).air && removePieceAt(20, 20) === '';
} catch (e) { return false; }
})()`));
}
flatWorld(run, 1024, 256, 200);
run(`
bot.x = 510; bot.y = 170;