diff --git a/src/simutrans/script/api/api_pathfinding.cc b/src/simutrans/script/api/api_pathfinding.cc
index 48be4e001..90edc02c9 100644
--- a/src/simutrans/script/api/api_pathfinding.cc
+++ b/src/simutrans/script/api/api_pathfinding.cc
@@ -122,6 +122,24 @@ bool way_builder_is_allowed_step(way_builder_t *bob, grund_t *from, grund_t *to)
 }
 
 
+SQInteger way_builder_get_step_cost(HSQUIRRELVM vm) // instance, from, to
+{
+	way_builder_t *bob = param<way_builder_t*>::get(vm, 1);
+	if (bob == NULL) {
+		return sq_raise_error(vm, "Not a way_planner_x instance"); // should not happen
+	}
+	grund_t *from = param<grund_t*>::get(vm, 2);
+	grund_t *to   = param<grund_t*>::get(vm, 3);
+
+	sint32 costs = 0;
+	if (from == NULL || to == NULL || !bob->is_allowed_step(from, to, &costs)) {
+		sq_pushnull(vm);
+		return 1;
+	}
+	return param<sint32>::push(vm, costs);
+}
+
+
 /**
  * Highest length a script may ask for: bridge_builder_t::find_end_pos counts the
  * tested lengths in an uint8, so 255 would never terminate.
@@ -241,6 +259,17 @@ void export_pathfinding(HSQUIRRELVM vm)
 	 * @param to to here, @p from and @p to must be adjacent.
 	 */
 	register_method(vm, way_builder_is_allowed_step, "is_allowed_step", true);
+	/**
+	 * Costs of the step from @p from to @p to, as used by the route search of the way builder.
+	 * These are the weights derived from the way_count_* settings, not an amount of money.
+	 * @param from from here
+	 * @param to to here, @p from and @p to must be adjacent.
+	 * @returns costs of the step, or null if @ref is_allowed_step refuses it
+	 * @typemask integer(tile_x,tile_x)
+	 */
+	register_function(vm, way_builder_get_step_cost, "get_step_cost", 3, "x t|x|y t|x|y");
+
+	log_squirrel_type("way_planner_x", "get_step_cost", "integer(tile_x, tile_x)");
 
 	end_class(vm);
 
diff --git a/src/simutrans/script/api/changelog.h b/src/simutrans/script/api/changelog.h
index 0cf6da01a..fd058a40f 100644
--- a/src/simutrans/script/api/changelog.h
+++ b/src/simutrans/script/api/changelog.h
@@ -15,6 +15,7 @@
  *
  * @section api-trunk Current trunk
  *
+ * - Added @ref way_planner_x::get_step_cost
  * - Changed @ref bridge_planner_x::find_end which accepts two optional parameters now, to search for longer bridges and for ends on flat ground
  * - Added @ref command_x::grid_lower, @ref command_x::grid_raise
  * - Added @ref settings::has_double_slopes, @ref settings::get_way_height_clearance
diff --git a/tests/all_tests.nut b/tests/all_tests.nut
index 4e595afad..0dfa07b97 100644
--- a/tests/all_tests.nut
+++ b/tests/all_tests.nut
@@ -33,6 +33,7 @@ include("tests/test_way_bridge")
 include("tests/test_way_bridge_maintenance")
 include("tests/test_way_bridge_maintenance_auto")
 include("tests/test_way_elevated")
+include("tests/test_way_planner")
 include("tests/test_way_road")
 include("tests/test_way_runway")
 include("tests/test_way_tram")
@@ -225,6 +226,11 @@ all_tests <- [
 	test_way_bridge_maintenance_auto,
 	test_way_bridge_maintenance_auto_equals_manual,
 	test_way_bridge_maintenance_auto_off,
+	test_way_planner_step_cost_ground,
+	test_way_planner_step_cost_slope,
+	test_way_planner_step_cost_refused,
+	test_way_planner_step_cost_invalid,
+	test_way_planner_step_cost_matches_is_allowed_step,
 	test_way_road_build_single_tile,
 	test_way_road_build_straight,
 	test_way_road_build_bend,
diff --git a/tests/tests/test_way_planner.nut b/tests/tests/test_way_planner.nut
new file mode 100644
index 000000000..389a70ea3
--- /dev/null
+++ b/tests/tests/test_way_planner.nut
@@ -0,0 +1,178 @@
+//
+// This file is part of the Simutrans project under the Artistic License.
+// (see LICENSE.txt)
+//
+
+
+//
+// Tests for way_planner_x
+//
+
+
+// way_count_* settings of the test map, which come from the savegame, not from simuconf.tab
+const WAY_COUNT_STRAIGHT =  1
+const WAY_COUNT_NO_WAY   =  3
+const WAY_COUNT_SLOPE    = 10
+
+
+function make_road_planner()
+{
+	local road = way_desc_x.get_available_ways(wt_road, st_flat)[0]
+	ASSERT_TRUE(road != null)
+
+	local planner = way_planner_x(player_x(0))
+	planner.set_build_types(road)
+	return planner
+}
+
+
+function test_way_planner_step_cost_ground()
+{
+	local pl = player_x(0)
+	local road = way_desc_x.get_available_ways(wt_road, st_flat)[0]
+	local planner = make_road_planner()
+	local remover = command_x(tool_remove_way)
+
+	local virgin = planner.get_step_cost(tile_x(2, 2, 0), tile_x(3, 2, 0))
+	ASSERT_EQUAL(virgin, WAY_COUNT_NO_WAY)
+
+	// same step, but now the target tile has a road on it
+	ASSERT_EQUAL(command_x.build_way(pl, coord3d(3, 1, 0), coord3d(3, 4, 0), road, true), null)
+
+	local on_way = planner.get_step_cost(tile_x(2, 2, 0), tile_x(3, 2, 0))
+	ASSERT_EQUAL(on_way, WAY_COUNT_STRAIGHT)
+
+	// existing ways must be cheaper than untouched ground
+	ASSERT_TRUE(on_way < virgin)
+
+	// clean up
+	ASSERT_EQUAL(remover.work(pl, tile_x(3, 1, 0), tile_x(3, 4, 0), "" + wt_road), null)
+
+	RESET_ALL_PLAYER_FUNDS()
+}
+
+
+function test_way_planner_step_cost_slope()
+{
+	local pl = player_x(0)
+	local planner = make_road_planner()
+
+	// a slope along the direction of travel: the step stays allowed, only dearer
+	ASSERT_EQUAL(command_x.set_slope(pl, coord3d(4, 4, 0), slope.south), null)
+
+	local flat_step  = planner.get_step_cost(tile_x(4, 2, 0), tile_x(4, 3, 0))
+	local slope_step = planner.get_step_cost(tile_x(4, 3, 0), tile_x(4, 4, 0))
+
+	// both steps are onto bare ground, so only the way_slope term differs
+	ASSERT_EQUAL(flat_step, WAY_COUNT_NO_WAY)
+	ASSERT_EQUAL(slope_step, WAY_COUNT_NO_WAY + WAY_COUNT_SLOPE)
+
+	// approaching the same slope from the other side costs the same
+	ASSERT_EQUAL(planner.get_step_cost(tile_x(4, 5, 0), tile_x(4, 4, 0)), slope_step)
+
+	// across the slope the way cannot follow it
+	ASSERT_FALSE(planner.is_allowed_step(tile_x(3, 4, 0), tile_x(4, 4, 0)))
+	ASSERT_EQUAL(planner.get_step_cost(tile_x(3, 4, 0), tile_x(4, 4, 0)), null)
+
+	// clean up
+	ASSERT_EQUAL(command_x.set_slope(pl, coord3d(4, 4, 0), slope.flat), null)
+
+	RESET_ALL_PLAYER_FUNDS()
+}
+
+
+function test_way_planner_step_cost_refused()
+{
+	local pl = player_x(0)
+	local planner = make_road_planner()
+	local setclimate = command_x(tool_set_climate)
+
+	ASSERT_EQUAL(setclimate.work(pl, coord3d(4, 4, 0), coord3d(4, 4, 0), "" + cl_water), null)
+	ASSERT_TRUE(tile_x(4, 4, 0).is_water())
+
+	// a road cannot enter water
+	ASSERT_FALSE(planner.is_allowed_step(tile_x(4, 3, 0), tile_x(4, 4, 0)))
+	ASSERT_EQUAL(planner.get_step_cost(tile_x(4, 3, 0), tile_x(4, 4, 0)), null)
+
+	// control: the step before it is still priced
+	ASSERT_TRUE(planner.is_allowed_step(tile_x(4, 2, 0), tile_x(4, 3, 0)))
+	ASSERT_EQUAL(planner.get_step_cost(tile_x(4, 2, 0), tile_x(4, 3, 0)), WAY_COUNT_NO_WAY)
+
+	// clean up
+	ASSERT_EQUAL(setclimate.work(pl, coord3d(4, 4, 0), coord3d(4, 4, 0), "" + cl_mediterran), null)
+
+	RESET_ALL_PLAYER_FUNDS()
+}
+
+
+function test_way_planner_step_cost_invalid()
+{
+	local planner = make_road_planner()
+
+	// off the map: no ground, so no step to price
+	ASSERT_FALSE(planner.is_allowed_step(tile_x(2, 2, 0), tile_x(40, 40, 0)))
+	ASSERT_EQUAL(planner.get_step_cost(tile_x(2, 2, 0), tile_x(40, 40, 0)), null)
+	ASSERT_EQUAL(planner.get_step_cost(tile_x(40, 40, 0), tile_x(2, 2, 0)), null)
+
+	// a height with no ground falls back to the map ground, for both methods
+	ASSERT_TRUE(planner.is_allowed_step(tile_x(2, 2, 5), tile_x(3, 2, 0)))
+	ASSERT_EQUAL(planner.get_step_cost(tile_x(2, 2, 5), tile_x(3, 2, 0)), WAY_COUNT_NO_WAY)
+
+	// from and to the very same tile
+	ASSERT_TRUE(planner.is_allowed_step(tile_x(2, 2, 0), tile_x(2, 2, 0)))
+	ASSERT_EQUAL(planner.get_step_cost(tile_x(2, 2, 0), tile_x(2, 2, 0)), WAY_COUNT_NO_WAY)
+
+	// is_allowed_step does not enforce adjacency, and neither does get_step_cost
+	ASSERT_TRUE(planner.is_allowed_step(tile_x(2, 2, 0), tile_x(7, 7, 0)))
+	ASSERT_EQUAL(planner.get_step_cost(tile_x(2, 2, 0), tile_x(7, 7, 0)), WAY_COUNT_NO_WAY)
+
+	RESET_ALL_PLAYER_FUNDS()
+}
+
+
+function test_way_planner_step_cost_matches_is_allowed_step()
+{
+	local pl = player_x(0)
+	local road = way_desc_x.get_available_ways(wt_road, st_flat)[0]
+	local planner = make_road_planner()
+	local remover = command_x(tool_remove_way)
+	local setclimate = command_x(tool_set_climate)
+
+	// a corner of the map with a way, water and a slope in it
+	ASSERT_EQUAL(command_x.build_way(pl, coord3d(3, 1, 0), coord3d(3, 4, 0), road, true), null)
+	ASSERT_EQUAL(setclimate.work(pl, coord3d(6, 6, 0), coord3d(6, 6, 0), "" + cl_water), null)
+	ASSERT_EQUAL(command_x.set_slope(pl, coord3d(5, 2, 0), slope.south), null)
+
+	// a cost must appear exactly when the step is allowed, it is the same call
+	local checked = 0
+	local priced  = 0
+
+	for (local x = 1; x < 8; ++x) {
+		for (local y = 1; y < 8; ++y) {
+			local from = tile_x(x, y, 0)
+
+			foreach (to in [tile_x(x + 1, y, 0), tile_x(x, y + 1, 0), tile_x(x, y, 0), tile_x(1, 7, 0)]) {
+				local allowed = planner.is_allowed_step(from, to)
+				local cost    = planner.get_step_cost(from, to)
+
+				ASSERT_EQUAL(cost != null, allowed)
+				if (allowed) {
+					ASSERT_TRUE(cost >= 0)
+					++priced
+				}
+				++checked
+			}
+		}
+	}
+
+	// both outcomes have to occur, otherwise the sweep proves nothing
+	ASSERT_TRUE(priced > 0)
+	ASSERT_TRUE(priced < checked)
+
+	// clean up
+	ASSERT_EQUAL(command_x.set_slope(pl, coord3d(5, 2, 0), slope.flat), null)
+	ASSERT_EQUAL(setclimate.work(pl, coord3d(6, 6, 0), coord3d(6, 6, 0), "" + cl_mediterran), null)
+	ASSERT_EQUAL(remover.work(pl, tile_x(3, 1, 0), tile_x(3, 4, 0), "" + wt_road), null)
+
+	RESET_ALL_PLAYER_FUNDS()
+}
