Files
deepgrain-foundry/src/sim/heat.js
T
44r0n7andClaude Opus 5.5 f57bdd6084 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>
2026-10-24 22:18:50 -04:00

266 lines
12 KiB
JavaScript

// Heat: conduction, melting and freezing, reactions, fire and air, steel.
// ---------- Heat ----------
function curAir() { return (tick & 0x7fff) + 1; }
function airflowAt(i) { const a = airS[i]; return a !== 0 && ((curAir() - a) & 0x7fff) < 8; }
// Air reaches a fuel grain if an open cell touches it, or sits just behind a
// porous neighbor (ash or loose coal), so a fuel bed burns a couple of grains deep.
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 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,
// 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 } };
function alloyMix(i, x, y, m) {
const a = ALLOY[m];
for (let d = 0; d < 4; d++) {
const j = neighborDir(i, x, y, (d + tick) & 3);
if (j < 0 || mat[j] !== MCU || rnd() > 0.25) continue;
mat[j] = a.into; wakeIdx(j); discover(a.into, j % W, (j / W) | 0, m, 'alloy');
if (rnd() < 1 / a.per) { mat[i] = a.into; wakeXY(x, y); return; }
}
}
function airy(m) { return m === EMPTY || m === FIRE; }
function hasO2(i, x, y) {
let m;
if (x > 0) { m = mat[i - 1]; if (airy(m) || (POROUS[m] && (m === ASH || m === SLAG || (x > 1 && airy(mat[i - 2]))))) return true; }
if (x < W - 1) { m = mat[i + 1]; if (airy(m) || (POROUS[m] && (m === ASH || m === SLAG || (x < W - 2 && airy(mat[i + 2]))))) return true; }
if (y > 0) { m = mat[i - W]; if (airy(m) || (POROUS[m] && (m === ASH || m === SLAG || (y > 1 && airy(mat[i - 2 * W]))))) return true; }
if (y < H - 1) { m = mat[i + W]; if (airy(m) || (POROUS[m] && (m === ASH || m === SLAG || (y < H - 2 && airy(mat[i + 2 * W]))))) return true; }
return false;
}
// Conducts heat between a and b. `da` = AMB(a) - AMB(b): only the excess over
// the local ambient flows, so the built-in geothermal gradient stays put.
function condPair(a, b, sc, nc, da) {
const ma = mat[a], mb = mat[b];
const d = temp[a] - temp[b] - da;
if (d < 0.05 && d > -0.05) return;
const k = KC[ma] < KC[mb] ? KC[ma] : KC[mb];
const q = d * k * sc;
temp[a] -= q * ICAP[ma];
temp[b] += q * ICAP[mb];
if (nc >= 0 && (q > 0.15 || q < -0.15)) { if (!hot[nc]) hotLvl[nc] = 2; hot[nc] = 6; }
}
function heatPass() {
for (let c = 0; c < NCH; c++) {
if (!hot[c]) continue;
let sc = 1;
if (hotLvl[c] === 1) { if (((tick + c) & 3) !== 0) continue; sc = 4; }
heatChunk(c, sc);
}
}
function heatChunk(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);
roofDirty[cx] = 1;
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) {
// 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;
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]--;
}
function neighborDir(i, x, y, d) {
if (d === 0) return y > 0 ? i - W : -1;
if (d === 1) return x < W - 1 ? i + 1 : -1;
if (d === 2) return y < H - 1 ? i + W : -1;
return x > 0 ? i - 1 : -1;
}
function react(i, x, y, m) {
const t = temp[i];
const fl = flags[i];
if (fl & F_BURN) { burn(i, x, y, m, t); return; }
if (IGN[m] !== 0 && t >= IGN[m] && (hasO2(i, x, y) || burningNear(i, x, y))) {
flags[i] = fl | F_BURN; life[i] = FUEL[m];
stats.ignitions++; discover(FIRE, x, y, m, 'ignite', t);
return;
}
const ph = PH[m];
if (ph === PH_LIQUID) {
if (m === WATER && t < -0.5) {
// freezes: into ice on something solid, into snowflakes in open air
if (rnd() < 0.02 - t * 0.004) {
const below = y < H - 1 ? PH[mat[i + W]] : PH_SOLID;
const to = below === PH_EMPTY || below === PH_GAS ? SNOW : ICE;
mat[i] = to; life[i] = 0; flags[i] = fl & F_NAT; wakeXY(x, y); discover(to, x, y, WATER, 'freeze', 0);
}
return;
}
if (HOTLIQ[m] && t > 600 && rnd() < 0.5) quench(i, x, y, m);
if (ALLOY[m]) alloyMix(i, x, y, m);
const fz = FRZ[m];
if (fz !== 0) {
if (t < fz) {
const L = life[i] - (fz - t) * 0.28;
if (L <= 0) {
const to = FRZTO[m];
mat[i] = to; life[i] = 0; temp[i] = fz - 1;
flags[i] = (fl & F_NAT) | (CASTMETAL[m] ? F_CAST : 0); // natural magma freezes into natural rock
wakeXY(x, y); discover(to, x, y, m, 'freeze', fz);
} else { life[i] = L; temp[i] = fz; }
return;
}
if (life[i] < 255 && t > fz + 1) {
const add = Math.min(255 - life[i], (t - fz) * 0.28);
life[i] += add; temp[i] = t - add / 0.28;
}
if (m === MFE && t >= 1530) steelCheck(i, x, y);
return;
}
const bp = BOIL[m];
if (bp !== 0) {
if (t > bp) {
const L = life[i] + (t - bp) * 2.0;
if (L >= 255) {
mat[i] = BOILTO[m]; life[i] = 0; temp[i] = bp + 3; flags[i] = 0;
wakeXY(x, y); discover(BOILTO[m], x, y, m, 'boil', bp);
if (rnd() < 0.02) emit({ t: 'sfx', s: 'sizzle', x, y });
} else { life[i] = L; temp[i] = bp; }
} else if (life[i] > 0) life[i] = life[i] > 2 ? life[i] - 2 : 0;
}
return;
}
if (ph === PH_GAS) {
if (CONDT[m] !== 0 && t < CONDT[m] && rnd() < 0.05 && !(m === STEAM && y < roofY[x])) { mat[i] = CONDTO[m]; life[i] = 0; wakeXY(x, y); discover(CONDTO[m], x, y, m, 'condense', CONDT[m]); }
return;
}
const mp = MELT[m];
if (mp !== 0 && t >= mp) {
if (rnd() < 0.03 + (t - mp) * 0.002) {
let to = MELTTO[m];
if (SMELTP[m] !== 0 && rnd() >= SMELTP[m]) to = t >= FRZ[MSLAG] ? MSLAG : SLAG; // the waste runs off molten when it is hot enough
if (PARTMAT[m]) { partLost(i); if (m === HULL && mach[i]) brokenMachines.push(mach[i]); }
mat[i] = to; flags[i] = 0; life[i] = PH[to] === PH_LIQUID ? 255 : 0; digp[i] = 0;
wakeXY(x, y); discover(to, x, y, m, SMELTP[m] !== 0 ? 'smelt' : 'melt', mp);
}
return;
}
if (m === CLAY) {
if (t >= 880 && rnd() < 0.004 + (t - 880) * 0.00012) { mat[i] = BRICK; discover(BRICK, x, y, CLAY, 'fire', 880); }
} else if (m === LCLAY) {
if (t >= 880 && rnd() < 0.004 + (t - 880) * 0.00012) { mat[i] = RBRICK; discover(RBRICK, x, y, LCLAY, 'fire', 880); }
} else if ((m === LWOOD || m === WOOD) && t >= 380) {
// baked hot with no air to burn in: wood turns into charcoal and gives off its volatiles
if (rnd() < 0.006 + (t - 380) * 0.00002) char(i, x, y, m);
} else if (m === GRASS) {
if (t >= 260) { mat[i] = DIRT; }
}
}
// Wood with heat but no air chars instead of burning (a kiln, or a smothered pile).
function char(i, x, y, m) {
const keep = rnd() < 0.6;
mat[i] = keep ? CHARCOAL : EMPTY; flags[i] = 0; life[i] = 0; digp[i] = 0;
wakeXY(x, y);
if (keep) discover(CHARCOAL, x, y, m, 'char');
}
// A very hot liquid meeting water flashes it to steam and is chilled hard;
// snow and ice next to it melt at once.
function quench(i, x, y, m) {
for (let d = 0; d < 4; d++) {
const j = neighborDir(i, x, y, d);
if (j < 0) continue;
const n = mat[j];
if (n === WATER) {
mat[j] = STEAM; temp[j] = 110; life[j] = 0; flags[j] = 0; wakeIdx(j); wakeHeatIdx(j);
temp[i] -= 40; discover(STEAM, j % W, (j / W) | 0, WATER, 'boil', 100);
if (rnd() < 0.05) emit({ t: 'sfx', s: 'sizzle', x, y });
} else if (n === SNOW || n === ICE) {
mat[j] = WATER; temp[j] = 20; life[j] = 0; flags[j] = 0; wakeIdx(j); wakeHeatIdx(j); temp[i] -= 15;
}
}
}
function burningNear(i, x, y) {
return (x > 0 && (flags[i - 1] & F_BURN)) || (x < W - 1 && (flags[i + 1] & F_BURN)) ||
(y > 0 && (flags[i - W] & F_BURN)) || (y < H - 1 && (flags[i + W] & F_BURN));
}
function burn(i, x, y, m, t) {
const forced = airflowAt(i);
const open = forced || hasO2(i, x, y);
// smothered wood chars into charcoal (a covered pile, like a charcoal clamp)
if (!open && (m === LWOOD || m === WOOD) && rnd() < 0.006) { char(i, x, y, m); return; }
// buried fuel next to burning fuel smolders: less heat, slower
if (!open && !burningNear(i, x, y)) {
if (rnd() < 0.004) flags[i] &= ~F_BURN;
return;
}
const target = forced ? FLAMEF[m] : open ? FLAME[m] : FLAME[m] * 0.95;
if (t < target) temp[i] = t + (target - t) * (forced ? 0.2 : open ? 0.07 : 0.035);
if (rnd() < (forced ? 0.5 : open ? 0.22 : 0.1)) {
const l = life[i] - 1;
if (l <= 0) {
if (PARTMAT[m]) partLost(i);
const to = BURNTO[m];
mat[i] = (to === ASH && rnd() < ASHP[m]) ? ASH : EMPTY;
flags[i] = 0; life[i] = 0; wakeXY(x, y);
if (mat[i] === ASH) discover(ASH, x, y, m, 'burn');
return;
}
life[i] = l;
}
if (open && rnd() < 0.3) {
const j = neighborDir(i, x, y, (rnd() * 4) | 0);
if (j >= 0 && mat[j] === EMPTY && part[j] !== P_INTAKE) {
mat[j] = FIRE; temp[j] = temp[i]; life[j] = 8 + ((rnd() * 18) | 0); flags[j] = 0;
wakeIdx(j);
}
}
if (rnd() < 0.012) {
const j = neighborDir(i, x, y, (rnd() * 4) | 0);
if (j >= 0 && mat[j] === EMPTY) { mat[j] = SMOKE; temp[j] = temp[i] * 0.8; life[j] = 200 + ((rnd() * 55) | 0); flags[j] = 0; wakeIdx(j); }
}
}
// Bessemer: air blown through molten iron burns off excess carbon (and gives off heat).
function steelCheck(i, x, y) {
if (airflowAt(i) && rnd() < 0.025) {
mat[i] = MST; life[i] = 255; temp[i] += 45; discover(MST, x, y, MFE, 'blow');
}
}