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
46 changes: 39 additions & 7 deletions docs/noise/vulcanus-cliffs-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2125,20 +2125,52 @@ the best-scoring collision model was the wrong one.
cliffs for it.** Higher `correct` is exactly the trap: recall is the half that
must not be traded. Note this means the ore rule and the lava rejection use
*different* cliff rectangles - the base `collision_box` and the per-orientation
one respectively - which is only defensible because the ore mechanism is open
and the base box is the shape it was measured with. If the mechanism is ever
found, revisit this first.
one respectively - which is defensible because the base box is the shape the
rule was measured with, and because naming the mechanism (below) did not name
its geometry. Revisit this first if the geometry is ever found.

### What is NOT claimed

**11 of the 31 are still unexplained** and the box is deliberately not widened
until they fall out: 10 are #99's run remainders and 1 is the cell our ore model
misses. `test/cliffOreRejection.spec.ts` pins that 11 so the gap stays tracked.

The **mechanism is still open**. This ships a characterised empirical rule -
one-way, additive, local, box-shaped - and the disassembly still says cliffs are
computed and placed before any resource entity exists, so whatever the engine is
really doing, it is not the collision test this models.
The **mechanism is SOLVED as of 2026-08-14**, and the note here used to say it
was open. It is `ResourceEntityPrototype::cliff_removal_probability`, a
documented field that defaults to **1.0** and that no shipped prototype
overrides - so it is invisible from the data and can only be seen by changing
it. `test/cliffRemovalProbability.spec.ts` holds the arms:

| arm | blob cells | cliff-vulcanus | resources | field |
| --- | --- | --- | --- | --- |
| control | 0 / 10 | 335 | 945 | 1 |
| field = 0 | **10 / 10** | 345 | **945** | 0 |
| resources OFF | 10 / 10 | 345 | 0 | 1 |

The middle arm is the whole result: all 945 resource entities stay exactly where
the control has them and the cliffs come back anyway, so no account resting on
the ore's mere PRESENCE survives. It is indistinguishable from the
resources-OFF arm, and 345 - 335 is exactly the ten blob cells, so the field
accounts for the effect entirely rather than partly.

**A prototype lever is a different instrument from a surface lever**, and that
is why five issues of `autoplace_controls` work could not reach this. A surface
setting can only add or remove the ore; a prototype field can leave it in place
and change one thing about it. `OracleOptions.extraDataLua` exists for that now,
and writes `data-final-fixes.lua` rather than `data.lua`, because the probe mod
declares no dependencies and may otherwise load before `space-age`.

**Nothing in the port changes.** At 1.0 the removal is unconditional, so the
box-overlap rejection is correct as written. Two things do change: the
disassembly finding below (cliffs are computed and placed before any resource
entity exists, so this is not the engine's collision test) stops being a dead
end and becomes the reason the effect had to be a *removal*; and the thin n=1
destroy-stage result gains outside support, since a field that removes cliffs
can only act on cliffs that already exist.

**Still open: the geometry.** Which rectangle the engine removes with is not
settled by naming the field, so the base-`collision_box` choice above remains an
empirical fit and the box still must not be tuned.

### Where it lives

Expand Down
10 changes: 6 additions & 4 deletions src/noise/cliffs/cliffPlacement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,12 @@ export interface CliffBands {
*
* **Deliberately opaque.** This module is planet-agnostic - the corner
* lattice, `crossesCliff` and the orientation table are engine behaviour - and
* the one rule that currently uses this hook is not engine behaviour at all
* but a characterised empirical one (Vulcanus's ORE -> CLIFF suppression, see
* `vulcanusOreRejection.ts`). Keeping it a bare predicate is what stops a
* planet-specific and mechanism-open rule from leaking into the shared core.
* the one rule that currently uses this hook is planet-specific and only
* partly explained (Vulcanus's ORE -> CLIFF suppression, see
* `vulcanusOreRejection.ts` - the mechanism is
* `ResourceEntityPrototype::cliff_removal_probability`, but the geometry it
* removes with is still an empirical fit). Keeping it a bare predicate is what
* stops such a rule from leaking into the shared core.
*
* It runs at the same site as `tileCollides` rather than as a filter over
* `placedCells`' return value so that the model the specs score is the model
Expand Down
49 changes: 43 additions & 6 deletions src/noise/cliffs/vulcanusOreRejection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,42 @@
* half-extent is 1.398 against the ores' 0.098: a point-at-tile-centre test
* explains the calcite cells and cannot explain the geyser ones.
*
* **NOT established: the mechanism.** This is a characterised empirical rule,
* not a port of a known engine path, and the difference matters enough to state
* at the top of the file. The obvious candidate is refuted:
* **ESTABLISHED 2026-08-14: the mechanism is
* `ResourceEntityPrototype::cliff_removal_probability`.** It defaults to
* **1.0**, and no shipped prototype overrides it - grepped across `base/`,
* `core/`, `space-age/`, `quality/` and `elevated-rails/` - so it is invisible
* from the data alone and can only be seen by changing it.
*
* Settled by a lever, and specifically by a PROTOTYPE lever rather than a
* surface one (`test/cliffRemovalProbability.spec.ts`). Switching the resources
* off, which is how #99 fixed the direction, removes everything about the ore
* at once and so can never say how. Zeroing one field instead leaves all 945
* resource entities exactly where the control has them:
*
* | arm | blob cells | cliff-vulcanus | resources | field |
* | --- | --- | --- | --- | --- |
* | control | 0 / 10 | 335 | 945 | 1 |
* | field = 0 | **10 / 10** | 345 | **945** | 0 |
* | resources OFF | 10 / 10 | 345 | 0 | 1 |
*
* The zeroed arm is indistinguishable from the no-resources arm, and 345 - 335
* is exactly the ten blob cells, so the field accounts for the effect entirely
* rather than partly. Each arm reads the field back off the running game, so an
* override that failed to apply cannot be mistaken for a term that does not
* matter.
*
* **No code changes, and that is the point of recording it.** At 1.0 the
* removal is unconditional, so the box-overlap rejection below is correct
* exactly as written. What changes is that its SHAPE is explained rather than
* fitted - a placed resource destroys the cliffs it collides with - and that
* the refutation below stops being a dead end and becomes the reason the effect
* had to be a removal at all.
*
* **Still NOT established: the geometry the engine removes with.** The base
* `collision_box` in point 1 below remains an empirical fit rather than a read
* of the code path, and naming the field licenses no tuning of it.
*
* The rival candidate was refuted before any of this, and stays refuted:
* `EntityMapGenerationTask::computeInternal` (`0x101622860`) calls
* `generateCliffs` at `+44` and `generateEntities` at `+148`, and `apply`
* (`0x101623b48`) calls `applyCliffs` at `+124` and `applyEntities` at `+164`,
Expand Down Expand Up @@ -58,7 +91,10 @@
* ore-suppressed cell whose neighbour can tell destruction from non-generation
* (`1546,1550.5`, a geyser cell) says DESTROYED, so the effect enters at
* `applyCliffs`/`Surface::wouldCollide` and not at `crossingsForChunk`. n=1 -
* the oracle is thin here and the spec says so.
* the oracle is thin here and the spec says so. **That thin result is now
* corroborated by something other than itself:** a field literally named
* `cliff_removal_probability` can only act on a cliff that already exists, so
* "destroyed rather than never queued" is what the mechanism predicts.
*
* **Consequence for anyone about to widen the box:** it would not be modelling a
* known code path, because the engine's entity collision provably is not this
Expand All @@ -72,8 +108,9 @@
* (`CLIFF_ORIENTATION_COLLISION_BOX`). Those are materially different shapes
* - the base box is `+/-0.988 x +/-0.488`, while orientation 4's rotbb is
* `[-3.5,-3,4.5,3]`. The base box is the one the rule was measured with, and
* since the mechanism is open there is nothing that says the ore rule should
* reuse the collision path's shape. `test/cliffOreRejection.spec.ts` scores
* naming the mechanism does not settle its geometry - a removal test need not
* reuse the collision path's shape, and nothing yet says which shape it does
* use. `test/cliffOreRejection.spec.ts` scores
* BOTH so the choice is a recorded measurement rather than an assumption -
* which is the lesson #88/#90 already paid for, where the best-scoring
* collision model was the wrong one because it absorbed an unrelated defect.
Expand Down
20 changes: 14 additions & 6 deletions test/cliffOreActsAtDestroyStage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,20 @@ import { withCtxDefaults } from "../src/noise/eval/ctx";
* surface before chunk N+1's cliffs are applied, and it still cannot matter).
*
* **The consequence that matters for #84.** The box-overlap model in
* `vulcanusOreRejection.ts` is a characterised empirical rule that does not
* correspond to the engine's collision test. Widening its box until the
* remaining cells fall out would not be modelling a known code path; it would be
* fitting a shape to an unexplained effect, which is exactly what #88 records as
* having shipped a wrong model that scored perfectly. The recall gap is real and
* worth closing - but not that way.
* `vulcanusOreRejection.ts` does not correspond to the engine's collision test.
* Widening its box until the remaining cells fall out would not be modelling a
* known code path; it would be fitting a shape to an effect whose geometry is
* still unknown, which is exactly what #88 records as having shipped a wrong
* model that scored perfectly. The recall gap is real and worth closing - but
* not that way.
*
* **Update 2026-08-14: the effect now has a name, and it predicts this
* result.** `ResourceEntityPrototype::cliff_removal_probability` (default 1.0)
* is the mechanism - see `cliffRemovalProbability.spec.ts`. A field that
* *removes* cliffs can only act on cliffs that already exist, so "destroyed
* rather than never queued" is what it predicts, and this spec's thin n=1
* result stops standing on its own. It does not name the geometry, so the
* warning above about widening the box is unchanged.
*/

const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] };
Expand Down
157 changes: 157 additions & 0 deletions test/cliffRemovalProbability.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { describe, expect, it } from "vite-plus/test";

import removal from "./fixtures/oracle-vulcanus-cliff-removal-probability.seed123456.json";

/**
* **The MECHANISM of the ore -> cliff exclusion: `cliff_removal_probability`.**
*
* `cliffOreDirection.spec.ts` settled the DIRECTION - resources suppress
* cliffs, not the reverse - by switching the resources off and regenerating.
* That is as far as switching something off can go. Removing the ore removes
* everything about the ore at once, so it can say which way the effect runs and
* never how.
*
* The lever here is a PROTOTYPE field, which is a different kind of lever:
* every one of the 945 resource entities stays exactly where the control has
* them, and one property of them changes.
* `ResourceEntityPrototype::cliff_removal_probability` defaults to **1.0**, and
* no shipped prototype overrides it - grepped across `base/`, `core/`,
* `space-age/`, `quality/` and `elevated-rails/`. It is therefore invisible
* from the data alone and can only be seen by changing it.
*
* It cannot be changed the way `autoplace_controls` and `cliff_settings` are,
* because those are surface settings and this is read at map-gen from the
* loaded prototype. `OracleOptions.extraDataLua` exists for exactly this, and
* writes `data-final-fixes.lua` rather than `data.lua`: the probe mod declares
* no dependencies, so Factorio may load it before `space-age`, at which point
* `data.raw.resource["tungsten-ore"]` does not exist yet and the override would
* silently edit nothing.
*
* **What this does NOT change: the port.** At 1.0 the removal is
* unconditional, so `vulcanusOreRejection.ts`'s box-overlap rejection is
* correct exactly as written and no code moves. What changes is that its header
* can stop saying the mechanism is unknown, and that the box-overlap SHAPE is
* explained rather than fitted - a placed resource destroys the cliffs it
* collides with.
*/

interface Ent {
x: number;
y: number;
name: string;
}
interface Proto {
type: string;
layers: string[];
box?: { lx: number; ly: number; rx: number; ry: number };
cliff_removal_probability?: number;
map_grid?: boolean;
}
interface Arm {
label: string;
zeroedCliffRemovalProbability: boolean;
autoplaceControls: Record<string, { frequency: number; size: number; richness: number }> | null;
effectiveAutoplace: Record<string, { frequency: number; size: number; richness: number }>;
cliffs: Ent[];
resources: Ent[];
protos: Record<string, Proto>;
}

const arms = removal.cases as unknown as Arm[];
const CONTROL = 0;
const ZEROED = 1;
const ORE_OFF = 2;

const vulcanusCliffs = (a: Arm): number =>
a.cliffs.filter((c) => c.name === "cliff-vulcanus").length;

/**
* The ten cells `#94` found the game leaves empty however `cliff_elevation` is
* routed onto them, and `#99` showed fill completely once the ore is switched
* off. Same list, verbatim, as `cliffOreDirection.spec.ts` - if it drifts, the
* two specs are no longer talking about the same thing.
*/
const BLOB = [
"178,138.5",
"178,142.5",
"178,146.5",
"178,150.5",
"182,138.5",
"182,142.5",
"182,146.5",
"182,150.5",
"186,138.5",
"186,142.5",
];
const blobHits = (a: Arm): number => {
const cells = new Set(
a.cliffs.filter((c) => c.name === "cliff-vulcanus").map((c) => `${String(c.x)},${String(c.y)}`),
);
return BLOB.filter((k) => cells.has(k)).length;
};

describe("the ore -> cliff exclusion is cliff_removal_probability", () => {
/**
* The distinguishing arm. The ore is still there - all 945 of it, the same
* count as the control - and every blob cell gets a cliff anyway. No account
* in which the exclusion is about the ore's PRESENCE survives this.
*/
it("zeroing the field restores every blob cell with the ore still in place", () => {
expect(blobHits(arms[CONTROL])).toBe(0);
expect(blobHits(arms[ZEROED])).toBe(10);

// Non-vacuity, both halves. The ore did NOT go away...
expect(arms[ZEROED].resources.length).toBe(945);
expect(arms[ZEROED].resources.length).toBe(arms[CONTROL].resources.length);
// ...and the arm really did run with the field changed, read back off the
// running game rather than echoed from what was written.
expect(arms[CONTROL].protos["tungsten-ore"].cliff_removal_probability).toBe(1);
expect(arms[ZEROED].protos["tungsten-ore"].cliff_removal_probability).toBe(0);
});

/**
* The field accounts for the effect ENTIRELY, not merely for some of it.
* Zeroing it is indistinguishable from deleting every resource, and the
* difference from the control is exactly the ten blob cells.
*/
it("zeroing the field is indistinguishable from having no resources at all", () => {
expect(vulcanusCliffs(arms[CONTROL])).toBe(335);
expect(vulcanusCliffs(arms[ZEROED])).toBe(345);
expect(vulcanusCliffs(arms[ORE_OFF])).toBe(345);
expect(vulcanusCliffs(arms[ZEROED])).toBe(vulcanusCliffs(arms[ORE_OFF]));
expect(vulcanusCliffs(arms[ZEROED]) - vulcanusCliffs(arms[CONTROL])).toBe(BLOB.length);

// The resources-OFF arm is the known control from cliffOreDirection: it
// reaches the same cliff count by removing the ore rather than by changing
// it, and its field is untouched.
expect(arms[ORE_OFF].resources.length).toBe(0);
expect(arms[ORE_OFF].protos["tungsten-ore"].cliff_removal_probability).toBe(1);
expect(arms[ORE_OFF].effectiveAutoplace["tungsten_ore"]?.size).toBe(0);
expect(arms[CONTROL].effectiveAutoplace["tungsten_ore"]?.size).toBe(1);
});

/**
* The default is what makes the ported rejection unconditional, and it is
* asserted here so that a future Factorio version lowering it - or a mod
* prototype arriving with a different value - fails loudly instead of
* silently invalidating `vulcanusOreRejection.ts`'s "always" assumption.
*/
it("every resource takes the 1.0 default, which is why the port needs no probability", () => {
for (const name of ["tungsten-ore", "calcite", "coal", "sulfuric-acid-geyser"]) {
expect(arms[CONTROL].protos[name].cliff_removal_probability).toBe(1);
}
});

/**
* Recorded here because the same read-back proved it and because it is the
* reason a rendered map preview cannot count resource entities: `map_grid`
* draws solid ores as a 2x2-block checkerboard. Only fluid and vent
* resources opt out, and the geyser is the one in this fixture.
*/
it("map_grid is on for the solid ores and off for the geyser", () => {
for (const name of ["tungsten-ore", "calcite", "coal"]) {
expect(arms[CONTROL].protos[name].map_grid).toBe(true);
}
expect(arms[CONTROL].protos["sulfuric-acid-geyser"].map_grid).toBe(false);
});
});
4 changes: 4 additions & 0 deletions test/fixtures/PROVENANCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,10 @@
"oracle-fastpow.seed123456.json": {
"factorioVersion": "2.1.12",
"evidence": "stated - captured 2026-08-05 by test/oracle/capture.ts fastpow against the installed binary (2.1.12, build 87038), which pnpm refs:sync --check reported in sync at capture time. Note 2.1.13 released the same day; its changelog contains no map-generation, noise, terrain, autoplace, cliff or map-exchange entries. The probe samples the noise machine's own `^` operator as `x ^ <exponent>` routed onto elevation on the default Nauvis surface, so fastApprox is compared to the operator DIRECTLY rather than through a downstream chain - every other fixture over fastApprox compares with a tolerance wide enough to hide the ~1e-5 effects at issue (#161, #162). Four series: 1/3 (the shipping cube root), 0.5, 2.5 and 2. The exponents are not interchangeable and the fixture is what established that: `^` has THREE behaviours, all at 123/123 - 1/3 and 2.5 reach Math::powSafe(float,float) and so the fastapprox log2/exp2 pair; 0.5 is an exact sqrt; an integral exponent takes powSafe's fcvtzs/scvtf fast path and is exact exponentiation by squaring. The 0.5 result was a refutation - the spec first asserted fastapprox there and the game disagreed at the first position. Positions are deliberately adversarial rather than a plain grid, which does not discriminate (12 evenly spaced points scored 12/12 for both candidate cube-root exponents): they include 24 points where a double 1/3 differs from f32(1/3), which settled #163 at 0/24 against 24/24, and points where the pre-9b49ebb single-rounding fastapprox differs from the per-operation rounding that replaced it (~30% of inputs). Every coordinate is a multiple of 1/2 a tile and therefore exact in the 1/256 MapPosition grid."
},
"oracle-vulcanus-cliff-removal-probability.seed123456.json": {
"factorioVersion": "2.1.14",
"evidence": "captured 2026-08-14 by test/oracle/capture.ts vulcanus-cliff-removal-probability against the installed binary, which pnpm refs:sync --check reported binary/factorio-data/lua-api all in sync at 2.1.14 at capture time; each arm also reads ResourceEntityPrototype::cliff_removal_probability back off the running game, so the version the arm ran under is recorded inside the fixture as well as here"
}
}
}
Loading