Skip to content

Commit 3ed4fc9

Browse files
committed
Fix map export round trip
1 parent 7701ca5 commit 3ed4fc9

5 files changed

Lines changed: 162 additions & 23 deletions

File tree

‎native/src/sbc/textures/ops/export.rs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,12 @@ pub(crate) fn export_diffuse(
2828
let texture = surface.borrow().texture.clone();
2929
let x1 = -1.0 + 2.0 * (i * tile_size) as f32 / width as f32;
3030
let x2 = -1.0 + 2.0 * ((i + 1) * tile_size) as f32 / width as f32;
31-
let y1 = -1.0 + 2.0 * ((tiles_z - j) * tile_size) as f32 / height as f32;
32-
let y2 = -1.0 + 2.0 * ((tiles_z - j + 1) * tile_size) as f32 / height as f32;
31+
// `save_texture_png` flips the framebuffer rows while reading them
32+
// into the top-down PNG consumed by mapcompile. Keep the engine's
33+
// tile rows in their native order here; reversing them as well
34+
// mirrors painted diffuse texture across the map on export.
35+
let y1 = -1.0 + 2.0 * (j * tile_size) as f32 / height as f32;
36+
let y2 = -1.0 + 2.0 * ((j + 1) * tile_size) as f32 / height as f32;
3337
let _ = gfx.bind_texture(&texture, 0, true);
3438
let _ = gfx.tex_rect(x1, y1, x2, y2, 0.0, 0.0, 1.0, 1.0);
3539
let _ = gfx.bind_texture(&texture, 0, false);

‎tools/e2e/run_commands.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import json
4+
import time
45

56
from run_env import command_fields
67

@@ -28,6 +29,64 @@ def engine_log(self) -> list[str]:
2829
return []
2930
return path.read_text(errors="replace").splitlines()
3031

32+
def log_cursor(self) -> int:
33+
"""A position in the engine log that can be passed to ``wait_for_log``.
34+
35+
Reloading a project starts a new game in the same process, so its ready
36+
line occurs more than once. Capturing a cursor lets a scenario wait
37+
for the *next* occurrence instead of sleeping for a guessed duration.
38+
"""
39+
return len(self.engine_log())
40+
41+
def wait_for_log(self, text: str, *, after: int = 0, timeout_s: float = 15.0) -> str:
42+
"""Wait until a newly written engine-log line contains ``text``.
43+
44+
The process is checked on every poll, which turns a reload crash into a
45+
useful immediate error rather than an arbitrary timeout.
46+
"""
47+
deadline = time.monotonic() + timeout_s
48+
while time.monotonic() < deadline:
49+
for line in self.engine_log()[after:]:
50+
if text in line:
51+
self.event("wait_for_log", text=text, line=line)
52+
return line
53+
self.assert_running()
54+
time.sleep(0.05)
55+
raise AssertionError(f"timed out waiting {timeout_s:.1f}s for log line containing {text!r}")
56+
57+
def wait_for_command(
58+
self,
59+
class_name: str,
60+
*,
61+
after: int = 0,
62+
timeout_s: float = 10.0,
63+
**expected: object,
64+
) -> dict:
65+
"""Wait for a committed command, optionally matching fields.
66+
67+
This is for asynchronous UI paths such as modal acceptance. Unlike an
68+
assertion, it returns as soon as the command bridge records the event.
69+
"""
70+
deadline = time.monotonic() + timeout_s
71+
while time.monotonic() < deadline:
72+
for entry in self.commands()[after:]:
73+
data = entry.get("data", {})
74+
if data.get("__preview") or data.get("className") != class_name:
75+
continue
76+
fields = command_fields(data)
77+
if all(
78+
key in fields
79+
and (want(fields[key]) if callable(want) else fields[key] == want)
80+
for key, want in expected.items()
81+
):
82+
self.event("wait_for_command", className=class_name, keys=sorted(expected))
83+
return data
84+
self.assert_running()
85+
time.sleep(0.05)
86+
raise AssertionError(
87+
f"timed out waiting {timeout_s:.1f}s for {class_name} with keys {sorted(expected)}"
88+
)
89+
3190
def commands(self) -> list[dict]:
3291
"""Every command envelope the UI sent to the command bridge.
3392
@@ -53,6 +112,10 @@ def commands(self) -> list[dict]:
53112
entries.append({**entry, "data": inner})
54113
return entries
55114

115+
def command_cursor(self) -> int:
116+
"""A position in the command log for ``wait_for_command(after=...)``."""
117+
return len(self.commands())
118+
56119
def assert_command(self, class_name: str, **expected: object) -> dict:
57120
"""Assert exactly one committed command of `class_name` carrying every
58121
key in `expected` was sent, and that those values match.

‎tools/e2e/run_input.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,23 @@ def click(self, x: int, y: int, button: int = 1, delay: float = 0.08) -> None:
113113
)
114114
nap(delay)
115115

116+
def click_settled(self, x: int, y: int, button: int = 1, delay: float = 0.08) -> None:
117+
"""Click after one engine input tick at the target position.
118+
119+
Most controls accept a compact ``click``. A toolbar action immediately
120+
after map editing can otherwise be hit-tested at the previous map
121+
position, so a scenario can opt into this precise move/press sequence
122+
instead of sleeping on a guessed UI transition.
123+
"""
124+
self.require_window()
125+
self.event("click_settled", x=x, y=y, button=button)
126+
run("xdotool", "mousemove", "--window", self.window, str(x), str(y))
127+
time.sleep(SETTLE)
128+
run("xdotool", "mousedown", str(button))
129+
nap(0.03)
130+
run("xdotool", "mouseup", str(button))
131+
nap(delay)
132+
116133
def move(self, x: int, y: int, delay: float = 0.08) -> None:
117134
self.require_window()
118135
self.event("move", x=x, y=y)

‎tools/e2e/scenarios/geometry.py‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -222,15 +222,18 @@
222222
# action order is UI presentation, while e2e scenarios care about the command
223223
# being invoked. Centres are measured in the running Rust panel.
224224
TOOLBAR: Final = {
225-
"new_project": (34, 165),
226-
"load": (74, 165),
227-
"import": (114, 165),
228-
"save": (154, 165),
229-
"save_as": (194, 165),
230-
"export": (234, 165),
231-
"copy": (274, 165),
232-
"cut": (314, 165),
233-
"paste": (354, 165),
225+
# .action-bar has 5dp left padding, 38dp buttons, and 1dp gaps.
226+
# Keep clicks at the centres; the former coordinates grazed the right edge
227+
# and were intermittently lost by RmlUi.
228+
"new_project": (24, 165),
229+
"load": (63, 165),
230+
"import": (102, 165),
231+
"save": (141, 165),
232+
"save_as": (180, 165),
233+
"export": (219, 165),
234+
"copy": (258, 165),
235+
"cut": (297, 165),
236+
"paste": (336, 165),
234237
}
235238

236239
# Modal controls are offsets from `dialog_left()`, not fixed screen positions.

‎tools/e2e/scenarios/map.py‎

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,9 @@ def stroke(x: int, y: int, dx: int, dy: int) -> None:
167167
# stroke is plainly visible in `stroke-*.png` and the diffs mean something.
168168
run_state.wheel(width // 3, height // 2, clicks=ZOOM_CLICKS, up=True, delay=0.5)
169169
click_field(run_state, left, MAP["terrain_size"], "400")
170-
click_field(run_state, left, MAP["terrain_strength"], "8")
170+
# Shape Modify applies a signed delta, so Strength controls a visible
171+
# relief; Height remains meaningful only when the Level brush follows.
172+
click_field(run_state, left, MAP["terrain_strength"], "1000")
171173
click_field(run_state, left, MAP["terrain_height"], "80")
172174
run_state.screenshot("brush-settings")
173175

@@ -616,21 +618,23 @@ def map_export(run_state: E2ERun) -> None:
616618
width, height = window_size(run_state)
617619

618620
# A project first: Export compiles the *saved* project, so it needs a path.
619-
# Save As creates it and reloads into it (the one unavoidable wait).
620-
run_state.click(*panel_point(left, TOOLBAR["save_as"]), delay=0.6)
621-
run_state.click(*dialog_point(run_state, DIALOG["file_name"]), delay=0.15)
621+
# Save As creates it and reloads into it. Wait for the second ready line,
622+
# rather than assuming every machine needs the old fixed eight seconds.
623+
reload_log = run_state.log_cursor()
624+
run_state.click_settled(*panel_point(left, TOOLBAR["save_as"]), delay=0.3)
625+
run_state.click_settled(*dialog_point(run_state, DIALOG["file_name"]), delay=0.1)
622626
run_state.type_text("ExportMap")
623-
run_state.key("Return", delay=0.15)
624-
# The one unavoidable wait: Save As restarts the engine into the new project.
625-
run_state.click(*dialog_point(run_state, DIALOG["file_ok_name"]), delay=8.0)
627+
run_state.click_settled(*dialog_point(run_state, DIALOG["file_ok_name"]), delay=0.1)
628+
run_state.wait_for_command("ReloadIntoProjectCommand")
629+
run_state.wait_for_log("finished loading and is now ingame", after=reload_log)
626630

627631
# Terrain: pick a pattern, arm Add, a fat brush, one sweep across the map.
628632
run_state.click(left + TAB_X["map"], TAB_Y, delay=0.15)
629633
run_state.click(*editor_point(left, "map", "terrain"), delay=0.3)
630634
run_state.click(*panel_point(left, MAP["terrain_pattern"]), delay=0.15)
631635
run_state.click(*panel_point(left, MAP_ACTIONS["terrain_add"]), delay=0.15)
632636
click_field(run_state, left, MAP["terrain_size"], "1200")
633-
click_field(run_state, left, MAP["terrain_height"], "80")
637+
click_field(run_state, left, MAP["terrain_strength"], "1000")
634638
_sweep(run_state, left, width, height)
635639
run_state.assert_command_at_least("TerrainShapeModifyCommand", 1)
636640

@@ -642,15 +646,20 @@ def map_export(run_state: E2ERun) -> None:
642646
run_state.click(*panel_point(left, MAP_ACTIONS["texture_paint"]), delay=0.15)
643647
_sweep(run_state, left, width, height)
644648
run_state.assert_any_command("TerrainChangeTextureCommand", paintMode="paint")
649+
run_state.key("Escape", delay=0.15)
650+
run_state.move(*panel_point(left, PARK_PANEL), delay=0.15)
651+
edited = run_state.screenshot("edited-before-export")
645652

646653
# Save the edits, then Export -> Spring archive (the default type).
654+
save_log = run_state.log_cursor()
647655
run_state.key("ctrl+s", delay=0.5)
648656
run_state.assert_command_at_least("SaveCommand", 1)
649-
run_state.click(*panel_point(left, TOOLBAR["export"]), delay=0.3)
650-
run_state.click(*dialog_point(run_state, DIALOG["file_name"]), delay=0.15)
657+
run_state.wait_for_log("save editor state:", after=save_log)
658+
run_state.click_settled(*panel_point(left, TOOLBAR["export"]), delay=0.2)
659+
run_state.click_settled(*dialog_point(run_state, DIALOG["file_name"]), delay=0.1)
651660
run_state.type_text("ExportMap")
652-
run_state.key("Return", delay=0.15)
653-
run_state.click(*dialog_point(run_state, DIALOG["file_ok_export"]), delay=0.3)
661+
run_state.click_settled(*dialog_point(run_state, DIALOG["file_ok_export"]), delay=0.1)
662+
run_state.wait_for_command("ExportSpringArchiveCommand")
654663
run_state.assert_command("ExportSpringArchiveCommand")
655664

656665
archive = _wait_for_archive(run_state, "ExportMap")
@@ -659,6 +668,49 @@ def map_export(run_state: E2ERun) -> None:
659668
if archive.stat().st_size < 1024:
660669
raise AssertionError(f"compiled archive is suspiciously small: {archive}")
661670

671+
# Prove the deliverable is usable: expose this session's archive to the map
672+
# scanner, create a project on it, and check that the painted material is
673+
# still visibly present. Comparing camera frames pixel-for-pixel here is
674+
# deliberately avoided: reloading rebuilds the terrain draw and its
675+
# sub-pixel shading is not frame-stable, even when the exported texture is.
676+
# `tiles` is orange while the base map is green, so this is a direct visual
677+
# assertion that the diffuse PNG made it through mapcompile and back in.
678+
map_region = (left // 2 - 260, height // 2 - 220, 520, 440)
679+
painted_before = run_state.count_color(edited, map_region, "#C87830", fuzz="18%")
680+
if painted_before < 5_000:
681+
raise AssertionError(
682+
f"texture paint was not visibly present before export ({painted_before} warm pixels)"
683+
)
684+
assert run_state.write_dir is not None
685+
maps_dir = run_state.write_dir / "maps"
686+
maps_dir.mkdir(parents=True, exist_ok=True)
687+
shutil.copyfile(archive, maps_dir / "ExportMap.sdz")
688+
reload_log = run_state.log_cursor()
689+
reload_commands = run_state.command_cursor()
690+
run_state.click_settled(*panel_point(left, TOOLBAR["new_project"]), delay=0.2)
691+
run_state.click_settled(*dialog_point(run_state, DIALOG["new_project_name"]), delay=0.1)
692+
run_state.type_text("FromExport")
693+
run_state.click_settled(*dialog_point(run_state, DIALOG["new_project_map"]), delay=0.2)
694+
run_state.click_settled(
695+
*dialog_point(run_state, dropdown_option(DIALOG["new_project_map"], 1)), delay=0.2
696+
)
697+
run_state.click_settled(
698+
*dialog_point(run_state, DIALOG["new_project_create_nosize"]), delay=0.1
699+
)
700+
run_state.wait_for_command("ReloadIntoProjectCommand", after=reload_commands)
701+
run_state.wait_for_log("finished loading and is now ingame", after=reload_log)
702+
reopened = run_state.screenshot("export-reopened")
703+
painted_after = run_state.count_color(reopened, map_region, "#C87830", fuzz="18%")
704+
if painted_after < 5_000:
705+
raise AssertionError(
706+
f"exported map lost its painted diffuse texture ({painted_after} warm pixels)"
707+
)
708+
if abs(painted_after - painted_before) > painted_before // 5:
709+
raise AssertionError(
710+
"exported diffuse texture moved within the map: "
711+
f"{painted_before} warm pixels before export, {painted_after} after reopening"
712+
)
713+
662714

663715
@scenario()
664716
def map_roundtrip(run_state: E2ERun) -> None:

0 commit comments

Comments
 (0)