Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 48 additions & 8 deletions docs/noise/cliffs-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ extracted from the low word only.
Also note the address above (`0x1016067a0`) is from the build this file was first
written against; under 2.1.12 the symbol is at `0x10160c3ac`. Re-derive from `nm`.

### The collision rejection - NOT ported, and it is issue #18's residual
### The collision rejection - PORTED 2026-07-30 (PR #73), and it is half of issue #18

`EntityMapGenerationTask::tryToAddCliff` (`0x101625038` in 2.1.12) does not just
record the cell. It switches on the `CliffOrientation` (a 20-entry jump table),
Expand Down Expand Up @@ -209,13 +209,53 @@ the rule with the real per-orientation boxes and our own tile resolver:
collapsing to 0.65 / 0.70. The real arm rejects almost only false positives.

So it explains region `[1500,1500]`'s over-placement essentially in full and
**does not** explain regions `[0,0]` or `[-1200,800]`, which barely move. It also
costs 6 true positives at `[0,0]`; the tile resolver is ~98% accurate overall and
plausibly worse at a lava boundary, but that is a hypothesis, not a measurement.

The rejection itself is **not implemented** - only the tables it needs are. Wiring
it in gives the cliff overlay a dependency on the Vulcanus tile resolver, which is
a render-cost decision, so it is deliberately a separate change.
**does not** explain regions `[0,0]` or `[-1200,800]`, which barely move.

#### The 10 true positives it costs are a one-tile boundary, and the resolver is not the residual

Wiring the rejection in gives the cliff overlay a dependency on the Vulcanus tile
resolver, so the obvious next suspect was that resolver: it is ~98.2% accurate on
the 19-way tile name, and the natural guess (written into this file and into
`vulcanusCliffEntities.spec.ts` when the rule landed) was that it is worse at a
lava boundary. **Measured 2026-07-30. The guess was wrong in its premise and
right in its conclusion, and neither half needed a Factorio run** - both fixtures
are committed.

- **The binary lava classification is EXACT.** `tryToAddCliff` only ever asks
whether a tile carries `water_tile`, never which tile it is, so the 19-way
argmax is the wrong thing to have been quoting. On all 381 positions of
`oracle-vulcanus-tile-names.seed123456` - 49 lava/lava-hot, 332 not - the
resolver lands on the correct side in **both** directions, zero mismatches.
All 7 name errors are non-lava/non-lava confusions inside one biome family.
Nor is it worse near a boundary: the 42 positions at Chebyshev distance 1 from
a tile of the opposite lava-ness are 42/42 correct on the full name.
`test/vulcanusTiles.spec.ts` now pins the zero.
- **But every real cliff is a negative-space oracle, and 10 of them contradict
us.** The game ran this same rejection and kept the cliff, so the game sees no
lava in that box. Over the 1400 real cliffs we place across the three regions,
10 boxes hold our lava - **0.71%** - and in all 10 the offending tile sits at
Chebyshev depth **1** inside our own lava, on its perimeter. Not one is deep.
Depth 1 is the common case for any lava tile, so that alone does not
discriminate; the control is region `[1500,1500]`'s 173 correct rejections,
which spread across the whole range (65 at depth 1, 52 deeper than 6). The
rule's real work is nowhere near the boundary.

**This rules the resolver out as a cause of the remaining over-placement**, which
is why it was worth measuring before reading any more of `generateCliffs`. A
resolver that UNDER-called lava would leave false positives sitting next to lava.
They do not: of region `[0,0]`'s 95 surviving false positives only 4.2% come
within 2 tiles of any lava, against 7.2% of its 222 matched true positives - the
wrong way round - and in regions `[1500,1500]` and `[-1200,800]` the bulk (42/62
and 20/29) are more than 8 tiles from the nearest lava tile. Whatever remains is
not a lava question, and it is not a tile question.

One cheap fixture improvement fell out of this and has **not** been done: the
cliff dump records only `{x, y, name}`. Adding each entity's `cliff_orientation`
would turn the fixture into a direct oracle for `CLIFF_CODE_TO_ORIENTATION` end
to end - today that table is validated only against the binary's own jump table -
and would give the true collision box for the ~60 real cliffs per region we miss
entirely, which is currently unobtainable because we have no orientation for a
cell we never place.

#### `rotbb` boxes

Expand Down
38 changes: 34 additions & 4 deletions test/vulcanusCliffEntities.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,40 @@ describe("Vulcanus cliff placement vs find_entities", () => {
// **Reported rather than smoothed over: recall gets WORSE in two regions.**
// Region 0 loses 6 true positives (0.806 -> 0.784) and region 1 loses 4.
// Those are cells where the game placed a cliff and our tile resolver puts
// lava inside its box. The resolver is ~98.2% accurate overall
// (`vulcanusTiles.spec.ts`) and is plausibly worse at a lava boundary, but
// that is a hypothesis and has not been measured - it is the first thing to
// check before concluding the collision geometry is wrong.
// lava inside its box.
//
// **Those 10 are a one-tile boundary error, and that is measured now
// (2026-07-30), not assumed.** This comment used to carry the guess that
// the resolver "is plausibly worse at a lava boundary"; the guess was
// wrong in its premise and right in its conclusion, so both halves are
// worth stating:
//
// - The resolver is NOT worse at a lava boundary. Its binary lava/not
// classification - the only thing `tryToAddCliff` reads - is EXACT on
// all 381 oracle positions, 49 lava and 332 not, in both directions
// (`vulcanusTiles.spec.ts` now pins this at zero mismatches). The 42
// positions sitting directly on a lava boundary are 42/42 correct even
// on the full 19-way name.
// - It is nonetheless off by about one tile SOMEWHERE, because each real
// cliff the game placed is itself a negative-space oracle: the game ran
// this same rejection and kept the cliff, so the game sees no lava in
// that box. Over the 1400 real cliffs we place across the three regions,
// 10 boxes contradict that - 0.71% - and in **all 10** the offending
// tile sits at Chebyshev depth 1 inside our lava, i.e. on our own
// perimeter. Not one is deep water.
//
// Depth 1 is the common case for any lava tile, so that alone would not
// discriminate; region 1's 173 correct rejections are the control and they
// are spread right across the range (65 at depth 1, 52 deeper than 6). The
// rule's real work is untouched by the boundary.
//
// **This also rules the resolver out as a cause of the residual below**,
// which was the reason to measure it first. An under-calling resolver
// would leave false positives sitting next to lava; they do not. Of region
// 0's 95 surviving false positives only 4.2% come within 2 tiles of any
// lava, against 7.2% of its matched true positives - the wrong way round -
// and in regions 1 and 2 the bulk (42/62 and 20/29) are more than 8 tiles
// from the nearest lava tile. Whatever is left is not a lava question.
//
// A control run pins that the rejection is not just deleting cells at the
// background lava rate: sampling the same lava field 10,000 tiles away
Expand Down
49 changes: 49 additions & 0 deletions test/vulcanusTiles.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vite-plus/test";

import fixture from "./fixtures/oracle-vulcanus-tile-names.seed123456.json";
import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs";
import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog";

/**
Expand Down Expand Up @@ -49,4 +50,52 @@ describe("makeVulcanusTileResolver vs get_tile oracle", () => {
// remaining boundary-flip count.
expect(agreement).toBeGreaterThan(0.978);
});

/**
* The BINARY lava classification, separately - because that is the only thing
* the cliff collision rejection reads. `tryToAddCliff` asks "does this tile
* carry `water_tile`", never which tile it is, so the 19-way argmax above can
* confuse `volcanic-folds` for `volcanic-folds-flat` all day without moving a
* cliff. **It is exact**: measured 2026-07-30, all 49 `lava`/`lava-hot`
* positions and all 332 others land on the correct side, in both directions.
*
* Pinned at zero rather than at a fraction. Every one of the 7 name mismatches
* is a non-lava/non-lava confusion within a biome family, so nothing is being
* rounded away - and the standing hypothesis this falsifies was specific: the
* Vulcanus cliff rejection drops 10 real cliffs (issue #18), and
* `vulcanusCliffEntities.spec.ts` recorded the guess that the resolver is
* "plausibly worse at a lava boundary". It is not worse at a lava boundary;
* the 42 fixture positions that sit directly on one (Chebyshev distance 1 to a
* tile of the opposite lava-ness) are 42/42 correct even on the full name.
*
* **Know what this zero is worth before leaning on it.** Its sensitivity was
* measured by planting scale factors on `lava`'s probability (2026-07-30):
* `1.02` and `1.2` both still pass, `2`, `5` and `20` all fail. 381 sparse
* oracle positions do not sit close enough to a lava boundary in probability
* space to register a small shift, so this is a regression guard against a
* broken lava range expression, NOT a sub-tile boundary check. The boundary
* really is off by about a tile somewhere - the 10 dropped cliffs prove it -
* and this spec cannot see that. The thing that can is the negative-space
* oracle in `vulcanusCliffEntities.spec.ts`: a real cliff the game placed is a
* standing assertion that the game found no lava in its box.
*/
it("classifies lava exactly, which is what the cliff rejection reads", () => {
let ourLava = 0;
let gameLava = 0;
let mismatch = 0;
for (let i = 0; i < positions.length; i++) {
const p = positions[i];
const ours = VULCANUS_CLIFF_BLOCKING_TILES.has(resolve(p.x, p.y).name);
const game = VULCANUS_CLIFF_BLOCKING_TILES.has(want[i]);
if (ours) ourLava++;
if (game) gameLava++;
if (ours !== game) mismatch++;
}
// Non-vacuity: the fixture really does carry lava, and plenty of it. Without
// this, a resolver that returned a constant non-lava tile would pass the
// mismatch assertion by agreeing with an empty set.
expect(gameLava).toBe(49);
expect(ourLava).toBe(49);
expect(mismatch).toBe(0);
});
});