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 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. 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) ## 2026-10-29: t79 preserve progress before world import (Codex)
- importWorldFile uses GAME.running to await saveWorld('auto', true) before reading - 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) { 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.'); 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; 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]]) : []; 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; 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); 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(); computeAmbient(); fillNoise();
mat.set(buffers.mat); life.set(buffers.life); flags.set(buffers.flags); 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 for (let i = 0; i < N; i++) {
blockP.set(buffers.blockP); blockV.set(buffers.blockV); filtersDirty = true; rebuildProcessors(); if (mat[i] >= NMAT) mat[i] = EMPTY;
for (let b = 0; b < NB; b++) if (blockP[b] === BP_RACK) rackState.set(b, newRack()); 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 : []) { for (const entry of Array.isArray(meta.racks) ? meta.racks : []) {
if (!Array.isArray(entry)) continue; if (!Array.isArray(entry)) continue;
const [b, q, open] = entry; 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')); rect(run, 200, 160, 203, 163, run('EMPTY'));
run('placeBlock(BP_WALL, RSTONE, 50, 40, true)'); 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('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)'); 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)'); ticks(run, 300); const h = hash();
run('generateWorld(42, 256, 256, null); fogLook = function () {}'); ticks(run, 300); 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. // 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 { load, suite, flatWorld, rect, count, ticks } = require('./harness');
const { run } = load(); const { run } = load();
const { check, done } = suite(); 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); flatWorld(run, 1024, 256, 200);
run(` run(`
bot.x = 510; bot.y = 170; bot.x = 510; bot.y = 170;