diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 564fc36..085a457 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -411,6 +411,12 @@ above, including:
shake; none climbs a 45° slope. So filters are flat or downhill
only; to sort while lifting, run a sloped belt up, then a filter. Not built yet.
+- **Conveyor drawing and sleep follow-up (t42/t43, 2026-10-26).** The owner wants visible
+ rubber ribs on the actual cleat raster, filled corrugated far walls behind the load, and
+ smooth continuous mesh with rails for inclined screens. Walls stop at the slope ends;
+ only loaded screens shake. Empty moving cleats must let chunks sleep. Keep pocket transport,
+ uphill throughput near 71 grains/s and exact sorting unchanged.
+
- **Sidewall belts with moving cleats (t42, 2026-10-26).** To replace the old full-column
grip, use moving barrier lines rather than material cells: the barrier mask blocks normal
powder movement without overwriting grains, terrain or parts. Cleats stand perpendicular
@@ -432,6 +438,17 @@ above, including:
Both looks use a sturdy mesh frame with a small tick-based shake only while loaded;
idle mesh has no running belt animation. No old uphill-save migration was added.
+- **Screw conveyors stay, in every direction (2026-10-26).** The owner asked whether screw
+ conveyors are still needed now that belts climb 45°, whether they're realistic, and what a
+ downward screw is for. Kept as they are: real plants use both (screws for steep, vertical and
+ enclosed runs; belts for long, cheap, high-volume ones), and a downward screw is the same
+ machine turning the other way, useful for letting material down at a steady rate.
+- **Stalls are the player's to fix, but visible; belts carry a realistic depth (t45,
+ 2026-10-26).** An overloaded belt line jammed against a screw conveyor above it. The owner
+ agreed that's realistic and stays the player's problem, but wants a warning on the stalled
+ belt and the animation stopped on everything affected, and belts limited to a realistic
+ load depth instead of carrying 96-grain stacks. Not built yet.
+
## Ideas not built yet
- **Concrete**, as a stronger wall material from rubble.
diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md
index ad0ba99..a546d0f 100644
--- a/docs/PROJECT_MAP.md
+++ b/docs/PROJECT_MAP.md
@@ -220,7 +220,7 @@ Starter furnace designs and the milestone list shown in the notebook.
- functions: smelterBlueprint
- variables: STARTER_DESIGNS, MILESTONES
-### src/sim/world.js (105 lines)
+### src/sim/world.js (106 lines)
World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking.
- functions: emit, bagCap, discover, alloc, depthU, depthY, meanSurface, 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, roofDirty, loadWakeOrder, loadWakePending, loadWakeIndex, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, screwLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats
@@ -230,9 +230,9 @@ Exploration only: never changes grains or the random stream.
- functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll
- variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH
-### src/sim/cells.js (218 lines)
+### src/sim/cells.js (223 lines)
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, enterFree, powder, passable, liquid, gas, wakeBotPowder, simMove
- variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL
### src/sim/heat.js (265 lines)
@@ -269,10 +269,10 @@ How belts, filters and screw conveyors move what is on or in them. What rests on
- functions: conveyorStep, carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, carryBeltsAndFilters, setFilter, filterRun, roomFor, screwSpread, screwIntake, diagonalScrewNext
- variables: CARRY_DEPTH, filterR, filterL, filtersDirty
-### src/sim/slope-conveyors.js (51 lines)
+### src/sim/slope-conveyors.js (66 lines)
Cleats are moving barriers, not material cells. A cleat supports a finite pocket; the bare face never holds grains.
-- functions: cleatFoot, pocketDepth, slopeCleatsTick, screenNudge
-- variables: CLEAT_SPACING, CLEAT_HEIGHT, CLEAT_REACH, SCREEN_NUDGE_TICKS, cleatCarrying, cleatMask, cleatCells, slopePhase
+- functions: cleatFoot, pocketDepth, slopeCleatsTick, screenNudge, wakeCleatLoad
+- variables: CLEAT_SPACING, CLEAT_HEIGHT, CLEAT_REACH, SCREEN_NUDGE_TICKS, cleatCarrying, cleatChunks, cleatMask, cleatCells, slopePhase
### src/sim/blocks.js (219 lines)
Building blocks: placing, integrity, removing, lines and rectangles of blocks.
@@ -361,12 +361,12 @@ Drawing (src/render/): browser only. This file: the renderer state R, colour tab
Propagated coloured light from glowing and burning cells.
- functions: builtForLight, looseForLight, computeLight
-### src/render/slope-belts.js (150 lines)
+### src/render/slope-belts.js (176 lines)
Drawing only: the face stays on its simulation cells; the band hangs below it.
-- functions: slopeBeltConnections, flatBeltCaps, prepareSlopeBelts, slopeBeltSegment, slopeBeltCorner, slopeBeltShape, slopeBeltAt, slopeBeltColor, screenLoaded, slopeDetailAt
-- variables: slopeBeltJoins, slopeBeltHalo, beltCaps, slopeAcross, slopeAlong, slopeDistance
+- functions: slopeBeltConnections, flatBeltCaps, prepareSlopeBelts, slopeBeltSegment, slopeBeltCorner, slopeBeltShape, slopeBeltAt, slopeBeltColor, screenLoaded, slopeWallShape, slopeWallAt, slopeDetailAt, slopePreviewCleat
+- variables: slopeBeltJoins, slopeBeltHalo, beltCaps, slopeAcross, slopeAlong, slopeDistance, slopeWallOwner
-### src/render/world.js (447 lines)
+### src/render/world.js (456 lines)
Scale colour without changing the smooth-look class.
- functions: fogColor, diagonalScrewJoins, diagonalScrewShape, diagonalScrewBlock, drawWorld
- variables: SCREW_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap
@@ -384,7 +384,7 @@ Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution.
- functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace
- variables: SM, smBytes
-### src/render/slope-belt-shader.js (85 lines)
+### src/render/slope-belt-shader.js (99 lines)
Smooth slope shading, kept beside the shared CPU geometry in slope-belts.js.
- variables: SM_SLOPE_HELPERS, SM_SLOPE_DRAW
@@ -481,7 +481,7 @@ Gamepad input.
- functions: padPressed, pollPad
- variables: PAD
-### src/ui/previews.js (248 lines)
+### src/ui/previews.js (250 lines)
Cursor previews: shared grid helpers, air streaks and tool preview dispatch.
- functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock
diff --git a/docs/agent-log.md b/docs/agent-log.md
index 014fab4..fda7735 100644
--- a/docs/agent-log.md
+++ b/docs/agent-log.md
@@ -4,6 +4,32 @@ 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-26: t42/t43 drawing and performance follow-up (Codex)
+
+- Filled the far sidewall down to the belt, behind grains, with two-tone pixel corrugation
+ and a continuous smooth 45-degree band. Rubber ribs use the physics mask in all eight
+ phases. Fixed smooth wall seams; matched ghosts, cursor previews and menu icons.
+ Smooth screens retain tinted mesh, now between solid rails without the pixel overlay;
+ loaded-only shake and transport are unchanged.
+- Empty cleats only wake nearby load; per-chunk flags bypass mask reads for distant powder.
+ Empty eight-block slope: 3 -> 0 moving areas after 120 ticks. Uphill sand/gravel/copper ore
+ 71.3 grains/s, coal 71.2; exact sorting retained. Isolated 1f8a89d -> current, median five
+ generated falling-world samples: no slopes 2.006 -> 2.063 ms/tick; 50 slopes 1.952 -> 2.076.
+ Powder probe with cached zero flag: 45.02 -> 50.56 ms (2.14M calls); residual 12 percent
+ microbenchmark overhead, about 3 percent in the generated no-slope world. Existing loaded
+ 576-block stress: 0.515 -> 1.064 ms/tick. slope-cost.js records the reproducible methodology;
+ raw results /tmp/t42-perf-{pre,after}-isolated.txt. Pre-t42 loaded slopes use old grip physics.
+- Files: sim cells/slope-conveyors/world; render slope-belts/slope-belt-shader/world;
+ UI previews/build-menu; t_slope_belt_art.js, t_smooth.py, slope-cost.js; PROJECT_MAP,
+ DESIGN and t42/t43 reports. Reused enterFree for the ordinary entry rules.
+- Final python3 test/run_all.py --browser: ALL PASSED (26 headless suites, 360 play,
+ 16 smooth checks; art suite 57). Before/after suite outputs differ only by five new
+ checks (/tmp/t42-output.diff). Opened shots/t{42,43}-follow-{belt,screen}-{pixel,smooth}-z{4,8}.png;
+ rechecked smooth belt after fixing seams; opened t42-follow-ghosts-{pixel,smooth}-z8.png
+ with both updated menu icons visible. Added sleep, border/load wake, wall geometry,
+ grain colour and smooth grain-class checks. Rebuilt dist, regenerated map; both tasks Review.
+ Owner playtest and hardware GPU appearance remain unverified. No commit.
+
## 2026-10-26: t42: sidewall belts with moving cleats (Codex)
- Continued the uncommitted prototype. `sim/slope-conveyors.js` supplies moving barrier
diff --git a/src/render/slope-belt-shader.js b/src/render/slope-belt-shader.js
index 9ebaf98..116e5e3 100644
--- a/src/render/slope-belt-shader.js
+++ b/src/render/slope-belt-shader.js
@@ -39,6 +39,22 @@ vec3 slopeShape(vec2 p, vec2 worldOrigin, float grade, int joins) {
`;
const SM_SLOPE_DRAW = `
+ if (nc == 31 || nc == 32) {
+ vec4 tag = cellAt(tTree, c);
+ float grade = nc == 31 ? -1.0 : 1.0;
+ vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w);
+ float sy = grade < 0.0 ? 4.0 : 0.0;
+ float foot = (local.x + grade * (local.y - sy)) * 0.5;
+ float depth = (grade * local.x - local.y + sy) * 0.70710678;
+ int wallFlags = int(tag.a * 255.0 + 0.5);
+ float ends = max((wallFlags & 8) == 0 ? -foot : -100.0, (wallFlags & 16) == 0 ? foot - 4.0 : -100.0);
+ float sd = max(ends * 1.41421356, max(-depth, depth - ${CLEAT_HEIGHT.toFixed(1)}));
+ float phase = float(wallFlags & 7);
+ float wave = 0.5 + 0.5 * sin((foot + w.x - local.x - phase) * 3.14159265);
+ vec3 rubber = mix(vec3(0.16,0.18,0.21), vec3(0.21,0.23,0.27), wave);
+ C = mix(C, rubber, 1.0 - smoothstep(-px, px, sd));
+ }
+
// Slopes use the flat belt's band, rollers and mesh in coordinates along the run.
if ((nc == 2 || (nc >= 23 && nc <= 30)) &&
(int(cellAt(tTree, c).a * 255.0 + 0.5) & 128) != 0 &&
@@ -58,9 +74,7 @@ const SM_SLOPE_DRAW = `
float rg = step(0.72, fract((axis - dir * (depth < 2.0 ? 1.0 : -1.0) * time * 30.0) * 0.5));
vec3 belt;
if (-sd < (mesh ? 1.1 : 0.8)) {
- belt = bandColor(lb, rg);
- if (mesh && ((joins & 32) != 0) == (depth < 2.0) && dot(own.rgb, vec3(1)) > 0.01)
- belt = mix(belt, own.rgb * (depth < 2.0 ? 1.0 : 0.8), 0.75 * rg);
+ belt = mesh ? mix(vec3(0.22,0.25,0.29), vec3(0.48,0.53,0.59), depth < 2.0 ? 0.85 : 0.35) : bandColor(lb, rg);
belt *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), -sd));
} else if (mesh) {
vec2 cg = abs(fract(alongWorld) - 0.5);
diff --git a/src/render/slope-belts.js b/src/render/slope-belts.js
index 2676e2e..1e6f7af 100644
--- a/src/render/slope-belts.js
+++ b/src/render/slope-belts.js
@@ -135,16 +135,42 @@ function screenLoaded(b) {
}
return false;
}
-// Far sidewalls stay behind the load. The cleat raster is the simulation barrier raster.
-function slopeDetailAt(i, x, y) {
+// Filled far wall, in belt coordinates. Project onto the face so ends stop at the slope ends.
+let slopeWallOwner = -1;
+function slopeWallShape(x, y, b, code = blockP[b]) {
+ const g = beltGrade(code), ox = b % BW * BS, oy = ((b / BW) | 0) * BS;
+ const sy = g < 0 ? BS : 0, lx = x - ox, ly = y - oy - sy;
+ const foot = (lx + g * ly) / 2, depth = (g * lx - ly) * Math.SQRT1_2;
+ return Math.max(-foot * Math.SQRT2, (foot - BS) * Math.SQRT2, -depth, depth - CLEAT_HEIGHT);
+}
+function slopeWallAt(x, y, smooth = false) {
+ slopeWallOwner = -1;
+ let best = smooth ? 0.8 : 0;
+ for (let by = Math.max(0, (y >> 2) - 1); by <= Math.min(BH - 1, (y >> 2) + 3); by++)
+ for (let bx = Math.max(0, (x >> 2) - 2); bx <= Math.min(BW - 1, (x >> 2) + 2); bx++) {
+ const b = by * BW + bx;
+ if (!isBelt(blockP[b]) || !isSlopeBelt(blockP[b])) continue;
+ const sd = slopeWallShape(x + 0.5, y + 0.5, b);
+ if (sd <= best) { best = sd; slopeWallOwner = b; }
+ }
+ return slopeWallOwner;
+}
+// The load occludes the far wall; ribs use exactly the simulation barrier raster.
+function slopeDetailAt(i, x, y, smooth = false) {
+ slopeWallOwner = -1;
if (!slopeBeltHalo[(y >> 2) * BW + (x >> 2)] && !cleatMask[i]) return 0;
- if (occ[i] || mach[i] || (mat[i] !== EMPTY && PH[mat[i]] !== PH_GAS) || part[i]) return 0;
- if (cleatMask[i]) return 1;
- for (let by = y >> 2; by <= Math.min(BH - 1, (y + 9) >> 2); by++) {
- const b = by * BW + (x >> 2), c = blockP[b];
- if (!isSlopeBelt(c)) continue;
- const depth = beltFaceY(c, x >> 2, by, x) - y;
- if (isFilter(c) ? depth === 2 : depth >= 6 && depth <= 7 + ((x + slopePhase[b]) & 1)) return isFilter(c) ? 3 : 2;
+ if (occ[i] || mach[i] || part[i]) return 0;
+ if (cleatMask[i] && PH[mat[i]] !== PH_SOLID) return 1;
+ if (mat[i] !== EMPTY && PH[mat[i]] !== PH_GAS) return 0;
+ return slopeWallAt(x, y, smooth) >= 0 ? 2 : 0;
+}
+
+function slopePreviewCleat(x, y, b, code) {
+ const bx = b % BW, by = (b / BW) | 0, g = beltGrade(code);
+ for (let h = 0; h <= CLEAT_REACH; h++) {
+ const foot = x - g * h;
+ if (foot >= bx * BS && foot < (bx + 1) * BS && foot % CLEAT_SPACING === 0 &&
+ y + h + 1 === beltFaceY(code, bx, by, foot)) return true;
}
- return 0;
+ return false;
}
diff --git a/src/render/world.js b/src/render/world.js
index bed0638..fa0e002 100644
--- a/src/render/world.js
+++ b/src/render/world.js
@@ -289,11 +289,12 @@ function drawWorld() {
const color = slopeBeltColor(sb, x, y, anim, br, bgc, bb);
r = color & 255; g = color >> 8 & 255; b = color >> 16 & 255;
}
- const slopeDetail = slopeDetailAt(i, x, y);
+ const slopeDetail = slopeDetailAt(i, x, y, sm);
if (slopeDetail) {
- r = slopeDetail === 1 ? 145 : slopeDetail === 3 ? 96 : 48;
- g = slopeDetail === 1 ? 125 : slopeDetail === 3 ? 106 : 52;
- b = slopeDetail === 1 ? 94 : slopeDetail === 3 ? 120 : 60;
+ const wave = slopeDetail === 2 && ((x + beltGrade(blockP[slopeWallOwner]) * y - slopePhase[slopeWallOwner]) & 3) < 2;
+ r = slopeDetail === 1 ? 100 : wave ? 51 : 43;
+ g = slopeDetail === 1 ? 104 : wave ? 55 : 47;
+ b = slopeDetail === 1 ? 112 : wave ? 63 : 55;
}
// incandescence
if (T > 450 && m !== EMPTY && incand) {
@@ -345,7 +346,13 @@ function drawWorld() {
((y - ((sb / BW) | 0) * BS + 8) << 16) | ((x - (sb % BW) * BS + 8) << 8);
if (slopeArt) { r = fm ? COLR[fm] : 0; g = fm ? COLG[fm] : 0; b = fm ? COLB[fm] : 0; }
}
- if (slopeDetail) cl = 4;
+ if (slopeDetail === 1) cl = 4;
+ else if (slopeDetail === 2) {
+ const wb = slopeWallOwner;
+ cl = beltGrade(blockP[wb]) < 0 ? 31 : 32;
+ tpx[row + bx] = ((128 | slopePhase[wb] | ((slopeBeltJoins[wb] & 12) << 1)) << 24) | cl |
+ ((y - ((wb / BW) | 0) * BS + 8) << 16) | ((x - wb % BW * BS + 8) << 8);
+ }
top = cl << 24;
fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 && !builtForLight(i, x, y) ? 0xff : 0);
}
@@ -396,7 +403,7 @@ function drawWorld() {
if (blocked) { tr = 230; tg = 70; tb = 60; }
else if (isWall(code) && WALL_OF[v]) { const wm = WALL_OF[v]; tr = (COLR[wm] + 90) / 2; tg = (COLG[wm] + 190) / 2; tb = (COLB[wm] + 230) / 2; }
const slope = isSlopeBelt(code), joins = slope ? slopeBeltConnections(gbx, gby, code, ghostBlocks) : 0;
- for (let dy = slope ? -4 : 0; dy < (slope ? 8 : BS); dy++) for (let dx = slope ? -4 : 0; dx < (slope ? 8 : BS); dx++) {
+ for (let dy = slope ? -9 : 0; dy < (slope ? 8 : BS); dy++) for (let dx = slope ? -5 : 0; dx < (slope ? 9 : BS); dx++) {
let sd = 0;
if (slope) {
const x = gbx * BS + dx, y = gby * BS + dy;
@@ -404,7 +411,9 @@ function drawWorld() {
const i = y * W + x, n = (y >> 2) * BW + (x >> 2);
if (n !== b && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[n])) continue;
sd = slopeBeltShape(x + 0.5, y + 0.5, b, joins, code);
- if (sd > 0) continue;
+ const rib = !isFilter(code) && slopePreviewCleat(x, y, b, code);
+ if (sd > 0 && !rib && (isFilter(code) || slopeWallShape(x + 0.5, y + 0.5, b, code) > 0)) continue;
+ if (rib) sd = -0.1;
}
if (isSlope(code) && !slopeCell(code, dx, dy)) continue;
const sx = gbx * BS + dx - vx0, sy = gby * BS + dy - vy0;
diff --git a/src/sim/cells.js b/src/sim/cells.js
index 583dd82..4b61faf 100644
--- a/src/sim/cells.js
+++ b/src/sim/cells.js
@@ -36,7 +36,12 @@ function move(i, j, x, y, nx, ny) {
// Can a grain of material m (phase ph) enter cell j moving by (dx,dy)?
// 0 = no, 1 = into empty, 2 = by displacing what is there.
function enter(m, ph, j, dx, dy) {
- if (occ[j] || (ph === PH_POWDER && !cleatCarrying && cleatCells.length && (cleatMask[j] || (dy > 0 && cleatMask[j - dx - dy * W])))) return 0;
+ if (ph === PH_POWDER && !cleatCarrying && cleatCells.length && (cleatMask[j] || (dy > 0 && cleatMask[j - dx - dy * W]))) return 0;
+ return enterFree(m, ph, j, dx, dy);
+}
+// Powder already knows whether its chunk has barriers; other callers use enter.
+function enterFree(m, ph, j, dx, dy) {
+ if (occ[j]) return 0;
const p = part[j];
if (p !== 0) {
if (IS_SCREW[p]) { if (ph === PH_POWDER && SCREW_DX[p] === -dx && SCREW_DY[p] === -dy) return 0; } // liquids and gases flow through freely
@@ -70,10 +75,10 @@ const SORTP_FUEL = 0.003;
// World generation settles with both at 1 and corner sealing off, so seeds keep the same world.
let LSTEP = 8, LFILM = 4;
let CORNER_SEAL = true;
-function powder(i, x, y, m) {
+function powder(i, x, y, m, barriers = cleatChunks[(y >> 5) * CW + (x >> 5)]) {
if (y >= H - 1) return;
- const b = i + W, barriers = cleatCells.length;
- if (enter(m, PH_POWDER, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; }
+ const b = i + W;
+ if ((!barriers || (!cleatMask[i] && !cleatMask[b])) && enterFree(m, PH_POWDER, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; }
// a grain at least twice as dense works its way down through a loose pile. Unlit fuel
// packs too tight for that, so a mixed furnace charge stays mixed until the fire gets in.
const t = mat[b];
@@ -86,16 +91,16 @@ function powder(i, x, y, m) {
const b = ((y + 1) >> 2) * BW + (x >> 2);
const d = isSlopeBelt(blockP[b]) ? (blockV[b] & 1 ? 1 : -1) : 0;
if (d && x + d >= 0 && x + d < W && IS_SCREW[part[i + d]]) {
- if (enter(m, PH_POWDER, i + d, d, 0)) move(i, i + d, x, y, x + d, y);
+ if ((!barriers || !cleatMask[i + d]) && enterFree(m, PH_POWDER, i + d, d, 0)) move(i, i + d, x, y, x + d, y);
return;
}
}
if (rnd() < STICK[m]) {
const d = (RS & 2) ? 1 : -1;
let nx = x + d;
- if (nx >= 0 && nx < W && (!CORNER_SEAL || (PH[mat[b]] !== PH_SOLID && (!barriers || !cleatMask[b])) || (PH[mat[i + d]] !== PH_SOLID && (!barriers || !cleatMask[i + d]))) && enter(m, PH_POWDER, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; }
+ if (nx >= 0 && nx < W && (!CORNER_SEAL || (PH[mat[b]] !== PH_SOLID && (!barriers || !cleatMask[b])) || (PH[mat[i + d]] !== PH_SOLID && (!barriers || !cleatMask[i + d]))) && (!barriers || (!cleatMask[i] && !cleatMask[b + d])) && enterFree(m, PH_POWDER, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; }
nx = x - d;
- if (nx >= 0 && nx < W && (!CORNER_SEAL || (PH[mat[b]] !== PH_SOLID && (!barriers || !cleatMask[b])) || (PH[mat[i - d]] !== PH_SOLID && (!barriers || !cleatMask[i - d]))) && enter(m, PH_POWDER, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
+ if (nx >= 0 && nx < W && (!CORNER_SEAL || (PH[mat[b]] !== PH_SOLID && (!barriers || !cleatMask[b])) || (PH[mat[i - d]] !== PH_SOLID && (!barriers || !cleatMask[i - d]))) && (!barriers || (!cleatMask[i] && !cleatMask[b - d])) && enterFree(m, PH_POWDER, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
}
}
function passable(m, ph, j, dx, dy) {
@@ -198,7 +203,7 @@ function simMove() {
const cx = ltr ? k : CW - 1 - k;
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), barriers = cleatChunks[rowBase + cx];
// 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;
for (let x = ltr ? x0 : x1 - 1; x !== xEnd; x += dx) {
@@ -210,7 +215,7 @@ function simMove() {
if (ph === PH_SOLID) continue;
const p = part[i];
if (IS_SCREW[p] && ph === PH_POWDER) continue; // a screw conveyor moves its own grains
- if (ph === PH_POWDER) powder(i, x, y, m); else if (ph === PH_LIQUID) liquid(i, x, y, m); else gas(i, x, y, m);
+ if (ph === PH_POWDER) powder(i, x, y, m, barriers); else if (ph === PH_LIQUID) liquid(i, x, y, m); else gas(i, x, y, m);
}
}
}
diff --git a/src/sim/slope-conveyors.js b/src/sim/slope-conveyors.js
index ec7993d..eb1342c 100644
--- a/src/sim/slope-conveyors.js
+++ b/src/sim/slope-conveyors.js
@@ -3,6 +3,7 @@ const CLEAT_SPACING = 8, CLEAT_HEIGHT = 6; // spacing in face cells, height in g
const CLEAT_REACH = Math.floor(CLEAT_HEIGHT * Math.SQRT1_2);
const SCREEN_NUDGE_TICKS = 4; // one downhill contact step per four ticks (15 steps/s), plus gravity
let cleatCarrying = false;
+let cleatChunks = new Uint8Array(0);
let cleatMask = new Uint8Array(0), cleatCells = [], slopePhase = new Uint8Array(0);
function cleatFoot(x, b, phase = slopePhase[b]) {
const d = blockV[b] & 1 ? 1 : -1;
@@ -16,7 +17,8 @@ function pocketDepth(x, b) {
return Math.max(0, CLEAT_REACH * 2 - distance);
}
function slopeCleatsTick(advance = true) {
- for (const i of cleatCells) { cleatMask[i] = 0; wakeIdx(i); }
+ for (const i of cleatCells) { cleatMask[i] = 0; wakeCleatLoad(i); }
+ cleatChunks.fill(0);
cleatCells.length = 0;
// Front first, just like flat transport. Each foot belongs to exactly one face cell.
for (const list of [beltR, beltL]) for (const i of list) {
@@ -37,7 +39,12 @@ function slopeCleatsTick(advance = true) {
if ((x & 3) === 0 && isBelt(c) && isSlopeBelt(c) && beltFaceY(c,x>>2,y>>2,x)===y && netSat(i)>0)
slopePhase[b] = (slopePhase[b] + 1) % CLEAT_SPACING;
}
- for (const i of cleatCells) { cleatMask[i] = 1; wakeIdx(i); }
+ for (const i of cleatCells) {
+ cleatMask[i] = 1; wakeCleatLoad(i);
+ const x = i % W, y = (i / W) | 0;
+ for (let cy = Math.max(0, (y - 1) >> 5); cy <= Math.min(CH - 1, (y + 1) >> 5); cy++)
+ for (let cx = Math.max(0, (x - 1) >> 5); cx <= Math.min(CW - 1, (x + 1) >> 5); cx++) cleatChunks[cy * CW + cx] = 1;
+ }
}
// A screen shakes only the grains touching its mesh, never the whole column above it.
function screenNudge(top, d, pass) {
@@ -49,3 +56,11 @@ function screenNudge(top, d, pass) {
if (i % W + d < 0 || i % W + d >= W || j >= N) return;
if (enter(m, PH_POWDER, j, d, 1)) move(i, j, i % W, (i / W) | 0, j % W, (j / W) | 0);
}
+
+// Only loose load can react to a barrier appearing or disappearing.
+function wakeCleatLoad(i) {
+ const x = i % W, y = (i / W) | 0;
+ for (let yy = Math.max(0, y - 1); yy <= Math.min(H - 1, y + 1); yy++)
+ for (let xx = Math.max(0, x - 1); xx <= Math.min(W - 1, x + 1); xx++)
+ if (PH[mat[yy * W + xx]] === PH_POWDER || occ[yy * W + xx]) wakeXY(xx, yy);
+}
diff --git a/src/sim/world.js b/src/sim/world.js
index 198abd8..ea9e6f5 100644
--- a/src/sim/world.js
+++ b/src/sim/world.js
@@ -52,6 +52,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);
AMB = new Float32Array(h);
loadWakeOrder = loadWakePending = null; loadWakeIndex = 0;
+ cleatChunks = 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
diff --git a/src/ui/build-menu.js b/src/ui/build-menu.js
index 7d1dbcc..9dd1605 100644
--- a/src/ui/build-menu.js
+++ b/src/ui/build-menu.js
@@ -12,8 +12,8 @@ const PIECE_ICONS = {
wall: '',
grate: '',
gate: '',
- belt: '',
- filter: '',
+ belt: '',
+ filter: '',
screw: '',
pipe: '',
pump: '',
diff --git a/src/ui/previews.js b/src/ui/previews.js
index c37bb80..04447fb 100644
--- a/src/ui/previews.js
+++ b/src/ui/previews.js
@@ -221,20 +221,22 @@ function previewBlock(code, x, y, w, h, stroke = false, direction = -1, bx = -1,
}
if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; }
else ctx.fill();
- // Preview cleats stand perpendicular to the face; screens have a raised far rail.
- ctx.beginPath();
- for (let k = 0; k < BS; k++) {
- const fy = beltFaceY(code, 0, 0, k);
- if (isFilter(code)) ctx.rect(x + k * scale, y + (fy - 2) * scale, scale, scale);
- else {
- ctx.rect(x + k * scale, y + (fy - 7 - ((ox + k) & 1)) * scale, scale, (2 + ((ox + k) & 1)) * scale);
- }
- if (!isFilter(code) && (((ox + k) % CLEAT_SPACING + CLEAT_SPACING) % CLEAT_SPACING === 0))
+ // Use the same filled wall profile as placed slopes; ribs stay on the physics feet.
+ if (!isFilter(code)) {
+ ctx.beginPath();
+ for (let yy = -9; yy < 8; yy += step) for (let xx = -5; xx < 9; xx += step)
+ if (slopeWallShape(ox + xx + step / 2, oy + yy + step / 2, b, code) <= 0)
+ ctx.rect(x + xx * scale, y + yy * scale, step * scale, step * scale);
+ const alpha = ctx.globalAlpha; ctx.globalAlpha *= 0.45; ctx.fill(); ctx.globalAlpha = alpha;
+ ctx.beginPath();
+ for (let k = 0; k < BS; k++) if (((ox + k) % CLEAT_SPACING + CLEAT_SPACING) % CLEAT_SPACING === 0) {
+ const fy = beltFaceY(code, 0, 0, k);
for (let h = 0; h <= CLEAT_REACH; h++)
ctx.rect(x + (k + beltGrade(code) * h) * scale, y + (fy - h - 1) * scale, scale, scale);
+ }
+ if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; }
+ else ctx.fill();
}
- if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; }
- else ctx.fill();
return;
}
code = slopeShape(code);
diff --git a/test/t_slope_belt_art.js b/test/t_slope_belt_art.js
index e1e8c62..12447e7 100644
--- a/test/t_slope_belt_art.js
+++ b/test/t_slope_belt_art.js
@@ -105,7 +105,7 @@ for (const grade of [-1,1]) {
check(`grade ${grade} phase ${phase} draws exactly the physical cleat raster`,run(`(()=>{
let drawn=0;
for(let i=0;i0;
})()`));
}
@@ -117,4 +117,40 @@ run('setCell(47*W+48,SAND,20,0);prepareSlopeBelts(0,0,W,H);');
check('loaded screen shakes the frame with the simulation tick',run('(slopeBeltJoins[12*BW+12]&64)!==0'));
run('tick=4;prepareSlopeBelts(0,0,W,H);');
check('loaded frame returns on the next shake beat',run('(slopeBeltJoins[12*BW+12]&64)===0'));
+flatWorld(run,160,120,112);
+run('for(let k=0;k<6;k++)placeBlock(BP_BELT_UP,1,12+k,20-k,true);rebuildPower();slopeCleatsTick(false);');
+check('wall reaches the belt and cleat height, and clips its free ends',run(`(()=>{
+ const b=20*BW+12,g=-1,sy=84;
+ for(const depth of [0.5,3,5.5])
+ if(slopeWallShape(50+g*depth*Math.SQRT1_2,sy-2-depth*Math.SQRT1_2,b)>0)return false;
+ return slopeWallShape(46,sy+2,b)>0 && slopeWallShape(54,sy-6,b)>0;
+})()`));
+check('far wall is filled down to the belt and grains cover it',run(`(()=>{
+ prepareSlopeBelts(0,0,W,H);
+ let wall=0;
+ for(let y=8;y<100;y++)for(let x=8;x<140;x++) {
+ const i=y*W+x;
+ if(slopeDetailAt(i,x,y)!==2)continue;
+ wall++;
+ mat[i]=SAND;const covered=slopeDetailAt(i,x,y)===0;mat[i]=EMPTY;
+ if(!covered)return false;
+ }
+ return wall>20;
+})()`));
+flatWorld(run,320,240,232);
+run('for(let k=0;k<8;k++)placeBlock(BP_BELT_UP,1,12+k,30-k,true);for(let t=0;t<120;t++)simTick();');
+check('idle empty slope lets every moving area sleep',run('awake.every(v=>v===0)'),run('awake.reduce((n,v)=>n+(v>0),0)'));
+check('cleat chunk flags include neighbours across chunk borders',run(`(()=>{
+ for(const i of cleatCells) {
+ const x=i%W,y=(i/W)|0;
+ for(let yy=y-1;yy<=y+1;yy++)for(let xx=x-1;xx<=x+1;xx++)
+ if(xx>=0 && xx=0 && yy>5)*CW+(xx>>5)])return false;
+ }
+ return cleatCells.length>0 && cleatChunks.some(v=>v===0);
+})()`));
+check('a load next to a cleat wakes after the empty line slept',run(`(()=>{
+ const i=cleatCells[0];setCell(i,SAND,20,0);awake.fill(0);
+ slopeCleatsTick(false);
+ return awake.some(v=>v>0) && awake[0]===0;
+})()`));
done();
diff --git a/test/t_smooth.py b/test/t_smooth.py
index ea91c1e..0d10257 100644
--- a/test/t_smooth.py
+++ b/test/t_smooth.py
@@ -76,6 +76,27 @@ with sync_playwright() as p:
check('built solids get no loose-grain outline', ground['built'][0] == ground['built'][1], ground['built'])
check('smooth: a thin darker seam separates ground and rubble', ground['smooth'][0] < ground['smooth'][1] * 0.9, ground['smooth'])
check('smooth: packed rubble keeps pebble shading', ground['texture'] > 30, ground['texture'])
+ wall = pg.evaluate('''() => {
+ GAME.paused = true; R.lens = false; SET.fog = false;
+ SET.lighting = 'off'; SET.glow = 'off'; SET.grain = false;
+ for(let y=20;y<150;y++)for(let x=40;x<240;x++)setCell(y*W+x,EMPTY,22,0);
+ for(let k=0;k<6;k++)placeBlock(BP_BELT_UP,1,25+k,25-k,true);
+ rebuildPower();slopeCleatsTick(false);prepareSlopeBelts(40,20,240,150);
+ let cell=-1;
+ for(let y=40;y<100 && cell<0;y++)for(let x=100;x<124;x++)
+ if(slopeDetailAt(y*W+x,x,y)===2){cell=y*W+x;break;}
+ if(cell<0)return null;
+ const x=cell%W,y=(cell/W)|0,control=80*W+180;
+ bot.x=118;bot.y=78;snapCamera();
+ setCell(cell,SAND,22,0);setCell(control,SAND,22,0);
+ noiseB[cell]=noiseB[control]=128;
+ const at=i=>R.px[(((i/W)|0)-R.vy0)*R.bw+i%W-R.vx0];
+ SET.look='pixel';drawWorld();const colors=[at(cell)&0xffffff,at(control)&0xffffff];
+ SET.look='smooth';drawWorld();const grainClass=at(cell)>>>24;
+ return {colors,grainClass};
+ }''')
+ check('sidewall stays behind the grain colour in pixels and smooth',
+ wall and wall['colors'][0] == wall['colors'][1] and wall['grainClass'] == 2, wall)
# a browser without hardware drawing gets the pixel look and a note
pg2 = b.new_page(viewport={'width': 800, 'height': 500})
pg2.add_init_script('localStorage.clear();')
diff --git a/tools/experiments/slope-cost.js b/tools/experiments/slope-cost.js
index 9ec0ed1..0e03632 100644
--- a/tools/experiments/slope-cost.js
+++ b/tools/experiments/slope-cost.js
@@ -20,15 +20,32 @@ assert(samples.length===5 && samples.every(n=>Number.isFinite(n) && n>0));
samples.sort((a,b)=>a-b);
console.log(`576 loaded slope blocks, median ${samples[2].toFixed(3)} ms/tick; samples ${samples.map(v=>v.toFixed(3)).join(', ')}`);
-// No slopes: isolate the extra guards in the shared powder hot loop.
+// No slopes: use the zero flag supplied once per chunk by simMove. Older powder ignores it.
flatWorld(run,512,384,376);
run('RS=7;for(let y=32;y<350;y+=2)for(let x=32;x<480;x++){mat[y*W+x]=SAND;mat[(y+1)*W+x]=STONE;}');
const powderSamples=[];
for(let sample=0;sample<6;sample++) {
const start=performance.now();
- run('for(let k=0;k<30;k++)for(let y=32;y<350;y+=2)for(let x=32;x<480;x++)powder(y*W+x,x,y,SAND);');
+ run('for(let k=0;k<30;k++)for(let y=32;y<350;y+=2)for(let x=32;x<480;x++)powder(y*W+x,x,y,SAND,0);');
if(sample)powderSamples.push(performance.now()-start);
}
assert(powderSamples.length===5 && powderSamples.every(n=>Number.isFinite(n) && n>0));
powderSamples.sort((a,b)=>a-b);
console.log(`2.14M powder calls without cleats, median ${powderSamples[2].toFixed(2)} ms`);
+
+// Generated terrain plus a replenished falling sand curtain, with zero or 50 slope blocks.
+for (const slopes of [0, 50]) {
+ const times = [];
+ for (let sample = 0; sample < 6; sample++) {
+ run(`generateWorld(12345, 1024, 640); weather.next=1e15; money=1e9;
+ for(let y=16;y<160;y++)for(let x=80;x<480;x++)setCell(y*W+x,EMPTY,AMB[y],0);
+ for(let k=0;k<${slopes};k++) {const bx=25+(k%10),by=32+Math.floor(k/10)*5-(k%10);
+ placeBlock(BP_BELT_UP,1,bx,by,true);}
+ for(let t=0;t<60;t++){for(let x=80;x<480;x++)if(mat[16*W+x]===EMPTY)setCell(16*W+x,SAND,AMB[16],0);simTick();}`);
+ const start = performance.now();
+ run(`for(let t=0;t<180;t++){for(let x=80;x<480;x++)if(mat[16*W+x]===EMPTY)setCell(16*W+x,SAND,AMB[16],0);simTick();}`);
+ if (sample) times.push((performance.now()-start)/180);
+ }
+ assert(times.every(n=>Number.isFinite(n) && n>0)); times.sort((a,b)=>a-b);
+ console.log(`generated falling world, ${slopes} slopes: ${times[2].toFixed(3)} ms/tick; ${times.map(n=>n.toFixed(3)).join(', ')}`);
+}