Faster roof scan and heat pass with identical results (t21)

- The roof is rescanned only in area columns that changed since the last scan: a quiet Huge
  world goes from 4.60 to 1.88 ms per scan (every 20 ticks).
- The heat pass skips react() for solids and powders below their first reaction threshold:
  Huge world digging 2.54 -> 2.34 ms/tick, quiet 1.39 -> 1.18.
- Every suite's output is identical apart from timing lines; seeds make the same worlds.
- test/t_sim_optimizations.js checks the dirty roof against a full scan and the heat loop
  against the reference loop byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 22:18:50 -04:00
co-authored by Claude Opus 5.5
parent d3fc1d2cbc
commit f57bdd6084
9 changed files with 160 additions and 11 deletions
+8 -7
View File
@@ -216,25 +216,25 @@ Starter furnace designs and the milestone list shown in the notebook.
- functions: smelterBlueprint - functions: smelterBlueprint
- variables: STARTER_DESIGNS, MILESTONES - variables: STARTER_DESIGNS, MILESTONES
### src/sim/world.js (98 lines) ### src/sim/world.js (101 lines)
World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking. World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking.
- functions: emit, bagCap, discover, alloc, depthU, depthY, computeAmbient, fillNoise, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx - functions: emit, bagCap, discover, alloc, depthU, depthY, computeAmbient, fillNoise, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx
- variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, awake, hot, hotLvl, powderArea, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, tubeLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats - variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, awake, hot, hotLvl, powderArea, roofDirty, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, tubeLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats
### src/sim/fog.js (52 lines) ### src/sim/fog.js (52 lines)
Exploration only: never changes grains or the random stream. Exploration only: never changes grains or the random stream.
- functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll - functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll
- variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH - variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH
### src/sim/cells.js (206 lines) ### src/sim/cells.js (207 lines)
Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove). Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove).
- functions: setCell, swapCells, move, enter, powder, passable, liquid, gas, wakeBotPowder, simMove - functions: setCell, swapCells, move, enter, powder, passable, liquid, gas, wakeBotPowder, simMove
- variables: SORTP, SORTP_FUEL, LSTEP, LFILM - variables: SORTP, SORTP_FUEL, LSTEP, LFILM
### src/sim/heat.js (246 lines) ### src/sim/heat.js (265 lines)
Heat: conduction, melting and freezing, reactions, fire and air, steel. Heat: conduction, melting and freezing, reactions, fire and air, steel.
- functions: curAir, airflowAt, alloyMix, airy, hasO2, condPair, heatPass, heatChunk, neighborDir, react, char, quench, burningNear, burn, steelCheck - functions: curAir, airflowAt, alloyMix, airy, hasO2, condPair, heatPass, heatChunk, neighborDir, react, char, quench, burningNear, burn, steelCheck
- variables: POROUS, SOIL, HOTLIQ, ALLOY - variables: POROUS, SOIL, HOTLIQ, REACT_MIN, ALLOY
### src/sim/machines.js (148 lines) ### src/sim/machines.js (148 lines)
Machines: the power network, footprints, placing and removing machines. Machines: the power network, footprints, placing and removing machines.
@@ -316,9 +316,9 @@ The bot: movement and collision with the world.
- functions: botBlocked, botOverlapSolid, botSlide, botStep - functions: botBlocked, botOverlapSolid, botSlide, botStep
- variables: SLIDE_MAX, SLIDE_STEP - variables: SLIDE_MAX, SLIDE_STEP
### src/sim/ledger.js (43 lines) ### src/sim/ledger.js (54 lines)
Milestone checks (the ledger) and the sky roof line used for cooling. Milestone checks (the ledger) and the sky roof line used for cooling.
- functions: checkLedger, updateRoof - functions: checkLedger, updateRoof, updateDirtyRoof, scanRoof
### src/sim/weather.js (87 lines) ### src/sim/weather.js (87 lines)
Weather: clouds from steam, rain and snow, and the world water count. Weather: clouds from steam, rain and snow, and the world water count.
@@ -475,6 +475,7 @@ The frame loop and boot.
- `test/t_physics.js`: Sand and other powders, molten metal and gases: heaps, sorting, sinking, setting, rising and fading. Water has its own suite (t_water.js). Tall worlds keep geothermal heat out of the way. - `test/t_physics.js`: Sand and other powders, molten metal and gases: heaps, sorting, sinking, setting, rising and fading. Water has its own suite (t_water.js). Tall worlds keep geothermal heat out of the way.
- `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore. - `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore.
- `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation. - `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation.
- `test/t_sim_optimizations.js`: Optimized paths must match the full roof scan and the original heat loop exactly.
- `test/t_smooth.py`: The smooth look: the WebGL pass compiles, draws, falls back cleanly, and the thermal lens still works. - `test/t_smooth.py`: The smooth look: the WebGL pass compiles, draws, falls back cleanly, and the thermal lens still works.
- `test/t_stone.js`: Uses for waste rock and sand: glass bricks from a fan furnace, the Saw, the silo and builders' orders. - `test/t_stone.js`: Uses for waste rock and sand: glass bricks from a fan furnace, the Saw, the silo and builders' orders.
- `test/t_water.js`: Water conservation, settling, ledge pouring, boundaries, pipes and phase changes. Tall worlds keep geothermal heat out of the ponds. - `test/t_water.js`: Water conservation, settling, ledge pouring, boundaries, pipes and phase changes. Tall worlds keep geothermal heat out of the ponds.
+17
View File
@@ -4,6 +4,23 @@ 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-24: t21 phase 3: speed-ups that change nothing (Claude Code, Codex)
- Profiled a Huge world (6144x2048) headless and in the browser. After a load every area is
awake: 8-15 ms/tick for the first 600 ticks, settling to about 0.7 ms/tick after 3 minutes. In
the browser, drawing is cheap; the full roof scan every 20 ticks was the largest single item.
- Kept: `updateDirtyRoof()` (`src/sim/ledger.js`) rescans only area columns marked by
`wakeXY`/`wakeHeatXY`/`simMove`/`heatChunk` (`roofDirty`); `updateRoof()` is still the full scan
for setup code. Quiet Huge world 4.60 -> 1.88 ms per scan, digging 4.67 -> 3.48.
`heatChunk` skips `react()` for solids and powders below their first reaction threshold
(`REACT_MIN`). Huge, ms/tick: digging 2.54 -> 2.34, quiet 1.39 -> 1.18.
- Reverted (no consistent gain): inlining `condPair`, one shared part rule for `enter`/`passable`.
- `test/t_sim_optimizations.js`: the dirty roof matches a full scan; `heatChunk` matches the
reference loop in `test/fixtures/heat-chunk.js` byte for byte, random state included (catches a
reaction added to `react()` but not to `REACT_MIN`).
- Every suite's output identical before and after apart from timings; seeds 777 and 4242
generate the same worlds. `run_all.py --browser` ALL PASSED.
## 2026-10-24: t21: piles near the bot sort quickly (Claude Code, Codex) ## 2026-10-24: t21: piles near the bot sort quickly (Claude Code, Codex)
- Owner: "a pile should sort when the player is nearby. the sorting shouldnt take long anyway." - Owner: "a pile should sort when the player is nearby. the sorting shouldnt take long anyway."
+1
View File
@@ -186,6 +186,7 @@ function simMove() {
for (let k = 0; k < CW; k++) { for (let k = 0; k < CW; k++) {
const cx = ltr ? k : CW - 1 - k; const cx = ltr ? k : CW - 1 - k;
if (!awake[rowBase + cx]) continue; if (!awake[rowBase + cx]) continue;
roofDirty[cx] = 1;
const x0 = cx << 5, x1 = Math.min(W, x0 + 32); const x0 = cx << 5, x1 = Math.min(W, x0 + 32);
// alternate rows scan left to right and right to left, so nothing drifts one way // alternate rows scan left to right and right to left, so nothing drifts one way
const dx = ltr ? 1 : -1, xEnd = ltr ? x1 : x0 - 1; const dx = ltr ? 1 : -1, xEnd = ltr ? x1 : x0 - 1;
+20 -1
View File
@@ -7,6 +7,20 @@ function airflowAt(i) { const a = airS[i]; return a !== 0 && ((curAir() - a) & 0
const POROUS = new Uint8Array(NMAT); POROUS[ASH] = 1; POROUS[SLAG] = 1; POROUS[COAL] = 1; POROUS[CHARCOAL] = 1; const POROUS = new Uint8Array(NMAT); POROUS[ASH] = 1; POROUS[SLAG] = 1; POROUS[COAL] = 1; POROUS[CHARCOAL] = 1;
const SOIL = new Uint8Array(NMAT); SOIL[DIRT] = 1; SOIL[GRASS] = 1; SOIL[LDIRT] = 1; const SOIL = new Uint8Array(NMAT); SOIL[DIRT] = 1; SOIL[GRASS] = 1; SOIL[LDIRT] = 1;
const HOTLIQ = new Uint8Array(NMAT); for (const m of [MAGMA, MCU, MFE, MST, MGL, MAG, MAU, MBZ, MBS, MSLAG]) HOTLIQ[m] = 1; const HOTLIQ = new Uint8Array(NMAT); for (const m of [MAGMA, MCU, MFE, MST, MGL, MAG, MAU, MBZ, MBS, MSLAG]) HOTLIQ[m] = 1;
// Cold solids and powders cannot react below their first applicable threshold.
const REACT_MIN = new Float64Array(NMAT).fill(Infinity);
for (let m = 0; m < NMAT; m++) {
if (PH[m] === PH_LIQUID || PH[m] === PH_GAS) REACT_MIN[m] = -Infinity;
else {
if (IGN[m] !== 0) REACT_MIN[m] = Math.min(REACT_MIN[m], IGN[m]);
if (MELT[m] !== 0) REACT_MIN[m] = Math.min(REACT_MIN[m], MELT[m]);
}
}
REACT_MIN[CLAY] = Math.min(REACT_MIN[CLAY], 880);
REACT_MIN[LCLAY] = Math.min(REACT_MIN[LCLAY], 880);
REACT_MIN[WOOD] = Math.min(REACT_MIN[WOOD], 380);
REACT_MIN[LWOOD] = Math.min(REACT_MIN[LWOOD], 380);
REACT_MIN[GRASS] = Math.min(REACT_MIN[GRASS], 260);
// Alloys: a molten additive grain turns neighbouring molten copper into the alloy, // Alloys: a molten additive grain turns neighbouring molten copper into the alloy,
// and is itself used up after about this many (the ratio: bronze ~1:7 tin, brass ~1:2 zinc). // and is itself used up after about this many (the ratio: bronze ~1:7 tin, brass ~1:2 zinc).
const ALLOY = { [MSN]: { into: MBZ, per: 7 }, [MZN]: { into: MBS, per: 2 } }; const ALLOY = { [MSN]: { into: MBZ, per: 7 }, [MZN]: { into: MBS, per: 2 } };
@@ -51,6 +65,7 @@ function heatPass() {
function heatChunk(c, sc) { function heatChunk(c, sc) {
const cx = c % CW, cy = (c / CW) | 0; const cx = c % CW, cy = (c / CW) | 0;
const x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32); const x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32);
roofDirty[cx] = 1;
const leftCold = cx > 0 && !hot[c - 1], upCold = cy > 0 && !hot[c - CW]; const leftCold = cx > 0 && !hot[c - 1], upCold = cy > 0 && !hot[c - CW];
let maxDev = 0, burning = false; let maxDev = 0, burning = false;
for (let y = y0; y < y1; y++) { for (let y = y0; y < y1; y++) {
@@ -80,7 +95,11 @@ function heatChunk(c, sc) {
} else rel = RELAX[m]; } else rel = RELAX[m];
t += (amb - t) * rel * sc; t += (amb - t) * rel * sc;
temp[i] = t; temp[i] = t;
if (m !== EMPTY) { react(i, x, y, m); m = mat[i]; t = temp[i]; } if (m !== EMPTY) {
// Test the stored Float32 temperature, just as react() reads it.
if ((flags[i] & F_BURN) || !(temp[i] < REACT_MIN[m])) react(i, x, y, m);
m = mat[i]; t = temp[i];
}
const dv = t > amb ? t - amb : amb - t; const dv = t > amb ? t - amb : amb - t;
if (dv > maxDev) maxDev = dv; if (dv > maxDev) maxDev = dv;
if (flags[i] & F_BURN) burning = true; if (flags[i] & F_BURN) burning = true;
+12 -1
View File
@@ -35,7 +35,18 @@ function checkLedger() {
// Topmost non-air cell of each column: air above it can shed heat to the sky. // Topmost non-air cell of each column: air above it can shed heat to the sky.
function updateRoof() { function updateRoof() {
for (let x = 0; x < W; x++) { scanRoof(0, W);
roofDirty.fill(0);
}
function updateDirtyRoof() {
for (let cx = 0; cx < CW; cx++) {
if (!roofDirty[cx]) continue;
scanRoof(cx << 5, Math.min(W, (cx + 1) << 5));
roofDirty[cx] = 0;
}
}
function scanRoof(x0, x1) {
for (let x = x0; x < x1; x++) {
let y = 0; let y = 0;
for (; y < H; y++) { const ph = PH[mat[y * W + x]]; if (ph === PH_SOLID || ph === PH_POWDER || ph === PH_LIQUID) break; } for (; y < H; y++) { const ph = PH[mat[y * W + x]]; if (ph === PH_SOLID || ph === PH_POWDER || ph === PH_LIQUID) break; }
roofY[x] = y; roofY[x] = y;
+1 -1
View File
@@ -3,7 +3,7 @@
function simTick() { function simTick() {
tick++; tick++;
tickStamp = (tickStamp % 255) + 1; tickStamp = (tickStamp % 255) + 1;
if (tick % 20 === 1) updateRoof(); if (tick % 20 === 1) updateDirtyRoof();
weatherTick(); weatherTick();
machinesTick(); machinesTick();
pipesTick(); pipesTick();
+4 -1
View File
@@ -13,7 +13,7 @@ let ents = []; // loose objects (bars)
let bgm; // background layer: trees. Liquids, powders and gases pass in front of it. let bgm; // background layer: trees. Liquids, powders and gases pass in front of it.
let flyers = []; // grains in flight, blown out of the bot let flyers = []; // grains in flight, blown out of the bot
let ghostBlocks = new Map(), ghostMolds = []; let ghostBlocks = new Map(), ghostMolds = [];
let awake, hot, hotLvl, powderArea; let awake, hot, hotLvl, powderArea, roofDirty;
let tick = 0, tickStamp = 1; let tick = 0, tickStamp = 1;
let machines = [null], molds = [], nets = [null], pcells = new Set(), powerDirty = true; let machines = [null], molds = [], nets = [null], pcells = new Set(), powerDirty = true;
let beltL = [], beltR = [], tubeLists = [[], [], [], [], []]; let beltL = [], beltR = [], tubeLists = [[], [], [], [], []];
@@ -50,6 +50,7 @@ function alloc(w, h) {
surfY = new Int16Array(w); roofY = new Int16Array(w); dirtY = new Int16Array(w); graniteY = new Int16Array(w); basaltY = new Int16Array(w); magmaY = new Int16Array(w); surfY = new Int16Array(w); roofY = new Int16Array(w); dirtY = new Int16Array(w); graniteY = new Int16Array(w); basaltY = new Int16Array(w); magmaY = new Int16Array(w);
AMB = new Float32Array(h); AMB = new Float32Array(h);
awake = new Uint8Array(NCH); hot = new Uint8Array(NCH); hotLvl = new Uint8Array(NCH); awake = new Uint8Array(NCH); hot = new Uint8Array(NCH); hotLvl = new Uint8Array(NCH);
roofDirty = new Uint8Array(CW).fill(1);
powderArea = new Uint8Array(NCH).fill(1); // unknown areas get one visit before being ruled out powderArea = new Uint8Array(NCH).fill(1); // unknown areas get one visit before being ruled out
machines = [null]; molds = []; nets = [null]; pcells = new Set(); powerDirty = true; machines = [null]; molds = []; nets = [null]; pcells = new Set(); powerDirty = true;
beltL = []; beltR = []; tubeLists = [[], [], [], [], []]; brokenMachines = []; beltL = []; beltR = []; tubeLists = [[], [], [], [], []]; brokenMachines = [];
@@ -85,6 +86,7 @@ function fillNoise() {
// ---------- Waking ---------- // ---------- Waking ----------
function wakeXY(x, y) { function wakeXY(x, y) {
const cx = x >> 5, cy = y >> 5, c = cy * CW + cx; const cx = x >> 5, cy = y >> 5, c = cy * CW + cx;
roofDirty[cx] = 1;
awake[c] = 3; awake[c] = 3;
const lx = x & 31, ly = y & 31; const lx = x & 31, ly = y & 31;
if (lx === 0) { if (cx > 0) awake[c - 1] = 3; } else if (lx === 31 && cx < CW - 1) awake[c + 1] = 3; if (lx === 0) { if (cx > 0) awake[c - 1] = 3; } else if (lx === 31 && cx < CW - 1) awake[c + 1] = 3;
@@ -92,6 +94,7 @@ function wakeXY(x, y) {
} }
function wakeHeatXY(x, y) { function wakeHeatXY(x, y) {
const c = (y >> 5) * CW + (x >> 5); const c = (y >> 5) * CW + (x >> 5);
roofDirty[x >> 5] = 1;
hot[c] = 6; hotLvl[c] = 2; hot[c] = 6; hotLvl[c] = 2;
} }
function wakeIdx(i) { wakeXY(i % W, (i / W) | 0); } function wakeIdx(i) { wakeXY(i % W, (i / W) | 0); }
+42
View File
@@ -0,0 +1,42 @@
// Reference heat loop before t21 phase 3; preserves conduction and Float32 store order.
function referenceHeatChunk(c, sc) {
const cx = c % CW, cy = (c / CW) | 0;
const x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32);
const leftCold = cx > 0 && !hot[c - 1], upCold = cy > 0 && !hot[c - CW];
let maxDev = 0, burning = false;
for (let y = y0; y < y1; y++) {
const amb = AMB[y], yw = y * W;
const dDown = y + 1 < H ? amb - AMB[y + 1] : 0, dUp = y > 0 ? AMB[y - 1] - amb : 0;
for (let x = x0; x < x1; x++) {
const i = yw + x;
if (x + 1 < W) condPair(i, i + 1, sc, x + 1 === x1 ? c + 1 : -1, 0);
if (y + 1 < H) condPair(i, i + W, sc, y + 1 === y1 ? c + CW : -1, dDown);
if (x === x0 && leftCold) condPair(i - 1, i, sc, c - 1, 0);
if (y === y0 && upCold) condPair(i - W, i, sc, c - CW, dUp);
let m = mat[i];
const ph = PH[m];
let t = temp[i];
let rel;
if (ph === PH_EMPTY || ph === PH_GAS) {
if (y > 0) {
const u = i - W, pu = PH[mat[u]];
const ex = (t - amb) - (temp[u] - AMB[y - 1]);
if ((pu === PH_EMPTY || pu === PH_GAS) && ex > 2) {
const a = ex * (sc > 1 ? 0.45 : 0.25);
t -= a; temp[u] += a;
if (y === y0 && upCold && a > 0.5) { hot[c - CW] = 6; hotLvl[c - CW] = 2; }
}
}
rel = y < roofY[x] ? 0.02 : 0.002;
} else rel = RELAX[m];
t += (amb - t) * rel * sc;
temp[i] = t;
if (m !== EMPTY) { react(i, x, y, m); m = mat[i]; t = temp[i]; }
const dv = t > amb ? t - amb : amb - t;
if (dv > maxDev) maxDev = dv;
if (flags[i] & F_BURN) burning = true;
}
}
if (burning || maxDev > 1.5) { hot[c] = 6; hotLvl[c] = (burning || maxDev > 60) ? 2 : 1; }
else hot[c]--;
}
+55
View File
@@ -0,0 +1,55 @@
// Optimized paths must match the full roof scan and the original heat loop exactly.
const fs = require('fs');
const { load, suite, flatWorld } = require('./harness');
const { run } = load();
const { check, done } = suite();
run(fs.readFileSync(__dirname + '/fixtures/heat-chunk.js', 'utf8'));
// Partial areas and changes at area boundaries, including material appearing in empty sky.
flatWorld(run, 99, 97, 80);
run('RS = 123; bot.x = 45; bot.y = 30;');
run(`
const originalWeatherTick = weatherTick;
weatherTick = function () {
if (tick % 20 === 1) {
const roof = roofY.slice(); updateRoof();
if (!roof.every((v, x) => v === roofY[x])) globalThis.__roofMiss = (globalThis.__roofMiss || 0) + 1;
}
originalWeatherTick();
};
`);
for (let k = 0; k < 200; k++) run(`
setCell((12 + ${k} % 60) * W + (${k} * 31 % W), ${k} % 3 ? WATER : EMPTY, 18, 0);
simTick();
`);
check('scheduled roof matches a full scan at area edges', !run('globalThis.__roofMiss'), run('globalThis.__roofMiss'));
run('weatherTick = originalWeatherTick;');
// Every material, threshold neighbours, fractional temperatures, burning, cold neighbours,
// alternating heat scales, and partial areas exercise both random calls and Float32 stores.
const snapshot = () => run(`JSON.stringify({
arrays: [mat, temp, life, flags, hot, hotLvl, part, digp, seen].map(a => Array.from(new Uint8Array(a.buffer))),
RS, stats, EV, brokenMachines
})`);
const setup = () => {
flatWorld(run, 67, 65, 60);
run(`{
RS = 456; seen.fill(0); EV.length = 0; stats.ignitions = 0; brokenMachines = [];
const ts = [-30, -0.5, 0, 0.5, 18, 97, 100, 260, 380, 600, 880, 1085, 1300, 1530, 1900];
for (let i = 0; i < N; i++) {
const m = (i * 37) % NMAT;
mat[i] = m; temp[i] = ts[i % ts.length] + ((i % 3) - 1) * 0.001;
life[i] = i % 256; flags[i] = i % 23 === 0 && FUEL[m] ? F_BURN : 0;
}
hot.fill(6); hot[0] = 0; hotLvl.fill(2); updateRoof();
}`);
};
for (const scale of [1, 4]) {
setup();
run(`for (let c = 1; c < NCH; c++) referenceHeatChunk(c, ${scale});`);
const expected = snapshot();
setup();
run(`for (let c = 1; c < NCH; c++) heatChunk(c, ${scale});`);
check('heat matches original bytes and random state at scale ' + scale, snapshot() === expected);
}
done();