Skip to content

Commit d0eb026

Browse files
gajopclaude
andcommitted
Add heightmap IO slice: native import/export/save/load
Native heightmap IO via the deferred IO seam (ctx.submit_io / drain_io): - ImportHeightmapCommand / ExportHeightmapCommand — 16-bit grayscale PNG - SaveMapCommand / LoadMapCommand — raw f32 .data, read/written by path - Import is undoable (snapshots pre-import heights; redo re-runs) - Dimensions from engine Terrain::GetHeightMapSize; SQUARE_SIZE from GAME_SQUARE_SIZE (no magic constants) - Editor wiring: dedicated "Heightmap (16-bit PNG)" export with extremes + .png default; import dispatched to the gadget; project save/load route the heightmap through native json.encode made UTF-8-aware: valid UTF-8 passes through, non-UTF-8 bytes are escaped \u00XX, so the Lua->native bridge emits valid JSON for any string. Tests: pure unit (exact .data roundtrip, PNG quantization roundtrip) plus in-engine smoke (import / load / roundtrip / save). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c41ac32 commit d0eb026

28 files changed

Lines changed: 1219 additions & 129 deletions

‎docs/porting/01-slices.md‎

Lines changed: 40 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,15 @@ spread across the slices they belong to, not deferred as a catch-all.
3030
| # | Slice | Status |
3131
|--:|-------|--------|
3232
| 0 | [Command infrastructure](#0-command-infrastructure) — trait + dispatch + undo/redo + compound + bridge + widget-notify | done (stable) |
33-
| 1 | [Terrain](#1-terrain) — shape / level / smooth / metal brushes | review (in stable; flipped to Rust-only; heightmap recalc fixed via `set_height_map_func`) |
34-
| 2 | [Heightmap](#2-heightmap) — load (sync) + import / export (async IO) | todo |
35-
| 3 | [Map settings](#3-map-settings) — sun / atmosphere / water / map-rendering | todo (setters bound; undo needs gl getters) |
33+
| 1 | [Terrain](#1-terrain) — shape / level / smooth / metal brushes | done (stable; flipped to Rust-only; heightmap recalc fixed via `set_height_map_func`) |
34+
| 2 | [Heightmap](#2-heightmap) — load (sync) + import / export (async IO) | review (in stable; first `IoJob` types + command IO seam; dispatch parallel) |
35+
| 3 | [Map settings](#3-map-settings) — sun / atmosphere / water / map-rendering | wip (not in stable) |
3636
| 4 | [Textures](#4-textures) — diffuse / shading / terrain texture / cache + grass + DNTS | review (in stable; Rust owns paint + cache + stroke close + undo/redo) |
37-
| 5 | [Objects](#5-objects) — units & features add / remove / set / move (needs s11n) | todo (large) |
38-
| 6 | [Areas](#6-areas) | todo |
39-
| 7 | [Teams & diplomacy](#7-teams--diplomacy) | todo |
40-
| 8 | [Project lifecycle](#8-project-lifecycle) — save / load / export / sync / start / stop + scenario-info | todo |
41-
| 9 | [Triggers + Variables](#9-triggers--variables) — depends on areas, teams | todo (last) |
37+
| 5 | [Objects](#5-objects) — units & features add / remove / set / move (needs s11n) | wip (not in stable) |
38+
| 6 | [Areas](#6-areas) | wip (not in stable) |
39+
| 7 | [Teams & diplomacy](#7-teams--diplomacy) | wip (not in stable) |
40+
| 8 | [Project lifecycle](#8-project-lifecycle) — save / load / export / sync / start / stop + scenario-info | wip — core (not in stable) |
41+
| 9 | [Triggers + Variables](#9-triggers--variables) — depends on areas, teams | wip (not in stable) |
4242

4343
Notes from review:
4444
- **Map settings** (sun/lighting/atmosphere/water/map-rendering) are general map
@@ -80,33 +80,20 @@ via an `inventory` `ModelFactory` and `Context` reaches them type-erased through
8080

8181
The bridge sends every command to Rust via `Spring.InvokeNativeModule(json.encode(msg:serialize()))`. Lua execution **also** runs (parallel) unless the class name appears in `nativeCommandsOnly`. Slice 0 leaves that allowlist empty — feature slices populate it as they land.
8282

83-
**Still open — required for full 1:1 once Rust is the *only* executor of undo/redo:**
84-
85-
These exist on the Lua side and are needed once Rust owns the undo stack. They
86-
are **not** yet ported. Slice 1 did not need them: flipping a command via
87-
`nativeCommandsOnly` only suppresses Lua's `cmd:execute()`
88-
([command_manager.lua](../../scen_edit/command/command_manager.lua) line ~129) —
89-
the `undoListAdd` / `notify` path (line ~133) is **not** gated, so during the
90-
parallel period Lua still owns undo/redo bookkeeping and the widget notify. These
91-
land with the slice that first moves the undo stack itself to Rust.
92-
93-
- `__cmd_id` allocation on every executed command (Rust counterpart to Lua's `idCount`).
94-
- Widget notify after `execute` / `undo` / `redo` / `clear_*` / `undo_list_add` (when it pops oldest) — Lua dispatches `WidgetCommandExecuted` / `WidgetCommandUndo` / `WidgetCommandRedo` / `WidgetCommandClearUndoStack` / `WidgetCommandClearRedoStack` / `WidgetCommandRemoveFirstUndo` to the widget. Rust needs the equivalent via `send_lua_uimsg` (a `lua_bridge` module — exists in wip, not yet in stable), matching `scen_edit/message/message_manager.lua`'s prefix-framed wire format.
95-
- `display()` method on commands (Lua returns `self.className`; `CompoundCommand` returns the first sub-command's display). Required for the `display` field of `WidgetCommandExecuted`.
96-
97-
**Out of scope for slice 0** (handled by their feature slices):
98-
- [merge_command.lua](../../scen_edit/command/merge_command.lua) — depends on `SetWaterParams` / `SetAtmosphere` / `SetSunParameters` inner commands; lands with slice 3 (map settings).
99-
- [resend_command.lua](../../scen_edit/command/resend_command.lua) — depends on s11n libs; lands with slice 8 (project lifecycle).
83+
During the parallel period Lua still owns undo/redo bookkeeping + widget notify
84+
(`nativeCommandsOnly` only suppresses Lua's `cmd:execute()`). Moving the undo
85+
stack itself to Rust — `__cmd_id`, widget-notify via `lua_bridge`, command
86+
`display()` — lands with the first slice that needs Rust-owned undo.
10087

10188
---
10289

10390
## 1. Terrain
10491

10592
Brush-based heightmap and metal-map editing. Engine-side via `Spring.SetHeightMap` / `Spring.SetMetalAmount` / `Spring.AddHeightMap`, all bound natively.
10693

107-
**Status:** in stable, awaiting review — four brush commands + brush-settings,
108-
flipped to Rust-only. Also lands shared infra: the IO-worker shell (no job types
109-
yet), the in-engine test harness, and the Lua `poll_io` driver.
94+
**Status:** done (stable) — four brush commands + brush-settings, flipped to
95+
Rust-only. Also landed shared infra: the IO-worker shell, the in-engine test
96+
harness, and the Lua `poll_io` driver.
11097

11198
The three heightmap brushes wrap their writes in `TerrainControl::set_height_map_func`
11299
so the engine recalcs (without it, heights change in data but the terrain doesn't
@@ -129,29 +116,20 @@ move) — see `SBC_PORT_MISSING_BINDINGS.md`. Metal needs no recalc.
129116

130117
## 2. Heightmap
131118

132-
Whole-map heightmap load + image import/export. **Not GL-blocked** — see
133-
`spring-bar/rust/crates/spring-native/SBC_PORT_MISSING_BINDINGS.md`. Load uses
134-
bound `set_height_map`; import/export do image decode/encode in Rust (the `image`
135-
crate), replacing what the spring-launcher used to do over IPC.
136-
137-
**Two parts:**
138-
- **Load (synchronous):** `LoadMapCommand` is a `Spring.SetHeightMap` loop over a
139-
raw float array that arrives in the command payload. No file IO, no background
140-
thread. Port directly.
141-
- **Import / export (async IO):** decode/encode an image file. File IO + image
142-
work runs on a background worker thread (must not touch the engine); the engine
143-
thread applies/reads heights. The IO-worker shell + the `widget:Update` poll
144-
already exist (landed with slice 1 as common infra) — this slice just adds the
145-
first concrete `IoJob`/`IoOutcome` types (and the `image` crate dep). See
146-
[docs/design/async-io.md](../design/async-io.md).
119+
Whole-map load + 16-bit-greyscale image import/export, all Rust-only. Load reads
120+
the `.data` file (LE `f32`) by **path** — Lua passes the path, not the bytes, so
121+
the heightmap never crosses the bridge. Import/export decode/encode on the
122+
background IO worker (the first concrete `IoJob`/`IoOutcome` types, via the
123+
`image` crate), replacing the spring-launcher round-trip; the engine thread
124+
applies/reads heights. See [docs/design/async-io.md](../design/async-io.md).
147125

148126
**Model:**
149127
- [scen_edit/model/heightmap.lua](../../scen_edit/model/heightmap.lua) (shared with slice 1)
150128

151-
**Commands:**
152-
- [load_map_command.lua](../../scen_edit/command/load_map_command.lua) — synchronous
153-
- [import_heightmap_command.lua](../../scen_edit/command/import_heightmap_command.lua) — async (image decode in Rust; was launcher `ImportSBHeightmap`)
154-
- [export_heightmap_command.lua](../../scen_edit/command/textures/export_heightmap_command.lua) — async (read heights via `get_ground_height`, encode image in Rust; was launcher `ConvertSBHeightmap`)
129+
**Commands** (all → Rust-only):
130+
- [load_map_command.lua](../../scen_edit/command/load_map_command.lua) — passes the heightmap file path; Rust reads the LE-`f32` `.data` and applies. (Driven by project load — [load_project_command_widget.lua](../../scen_edit/command/project/load_project_command_widget.lua) now passes the path.)
131+
- [import_heightmap_command.lua](../../scen_edit/command/import_heightmap_command.lua) — image decode in Rust; was launcher `ImportSBHeightmap`
132+
- [export_heightmap_command.lua](../../scen_edit/command/textures/export_heightmap_command.lua) — read live heights via `get_ground_height`, encode 16-bit PNG; was launcher `ConvertSBHeightmap`
155133

156134
---
157135

@@ -161,16 +139,11 @@ General map rendering config: sun lighting, atmosphere, water, map-rendering
161139
params. These are **map-wide config**, distinct from scenario *info* (project
162140
metadata, which is in slice 8). Should land early.
163141

164-
**Status / blocker:** mostly engine-blocked. The setters are bound but their
165-
param structs (`AtmosphereParams`, `SunLightingParams`, `WaterParams`,
166-
`MapRenderingParams`) are `_unused: u8` stubs in the generated bindings — calling
167-
them carries no data (no-ops) until the engine defines the struct fields + wires
168-
the apply. See the engine note, Category A. The one exception that ports today is
169-
`set_sun_parameters_command.lua` (`Spring.SetSunDirection` → bound
170-
`set_sun_direction(Float3, intensity)`). Undo for any of these additionally needs
171-
the `gl.*` getters (also unbound). These commands are `_execute_unsynced`, so the
172-
bridge would also need to route them to native (currently they go cross-state to
173-
the widget). Net: defer this slice until the engine structs are fleshed out.
142+
**Status:** implemented in wip, not yet in stable — all setters with partial-opts,
143+
undo snapshots via the `Gfx` getters. These are `_execute_unsynced`: they reach
144+
the single native module from the widget side (where `Gfx` is valid), so no bridge
145+
change is needed. `merge_command.lua` (coalesces successive edits into one undo
146+
step) lands here.
174147

175148
**Commands:**
176149
- [set_sun_lighting_command.lua](../../scen_edit/command/set_sun_lighting_command.lua)
@@ -185,51 +158,16 @@ the widget). Net: defer this slice until the engine structs are fleshed out.
185158

186159
## 4. Textures
187160

188-
Map texturing (terrain texture, diffuse, shading, grass, DNTS). The GL
189-
binding gap is gone: the engine exposes texture creation, render-to-texture,
190-
readback, image save, texture blits, and shaders through native `Gfx`.
191-
192-
**Status:** in stable for review. Rust owns the texture paint workflow:
193-
paint, cache, stroke close, and undo/redo. The paint/cache/stroke commands are
194-
`nativeCommandsOnly`. Optional shading texture creation still uses the existing
195-
editor path; Rust lazily mirrors newly enabled shading textures from handles
196-
carried by the paint command.
197-
198-
`Gfx` render-to-texture works directly from the command path (no `DrawScreen`
199-
deferral needed), confirmed headless by in-engine tests:
200-
- `engine_gfx_render_readback` — create FBO texture, clear, then `read_pixels` back
201-
while the FBO is bound.
202-
- `engine_gfx_blit` — fill a source FBO, blit to a destination FBO, read back.
203-
- `engine_gfx_shader_pass` — compile a fragment shader, draw a quad through it
204-
into an FBO, read back.
205-
206-
**Built + in-engine tested** (feature under `native/src/sbc/textures/{commands,model}`):
207-
- `textures/model/graphics.rs` — `create_fbo_texture` + `blit`; returns a
208-
`Texture` newtype handle.
209-
- `textures/model/texture_model/` — `TextureModel`, a container of components:
210-
`tiles` (1024² diffuse FBO tiles seeded from `get_map_square_texture`,
211-
registered back with `set_map_square_texture`), `cache` (brush/pattern FBO
212-
cache), `shading` (editable shading-texture FBO mirrors — `$ssmf_specular` /
213-
`$ssmf_emission` / `$ssmf_sky_refl` / `$ssmf_splat_distr` / `$detail` /
214-
`$ssmf_splat_normals:0..3` — bound through `SetMapShadingTexture`), `shaders`
215-
(the shader cache), and `history` (the active stroke + copy-on-write
216-
tile/shading undo/redo).
217-
- `textures/model/texture_drawing.rs` — rotated/offset texture-coordinate
218-
generation and the quad render pass used by the paint shaders.
219-
- `textures/model/shader_cache.rs` — VFS shader loading, blend-mode
220-
substitution, compile/cache, and sampler uniform binding.
221-
- `textures/model/draw/` — all paint modes (`paint`, `void`, `blur`, `height`,
222-
`dnts`) plus the shading-texture sister pass.
223-
- `textures/commands/terrain_change_texture_command.rs` — lazily initializes the
224-
atlas, computes the brush region, and dispatches by `paintMode`.
225-
- `textures/commands/terrain_change_texture_merged_command.rs` — closes one
226-
stroke into one native undo group; undo/redo route through the native command
227-
manager.
228-
- `textures/commands/cache_texture_command.rs` — `CacheTextureCommand`.
229-
- Optional shading textures created by the editor are picked up lazily by Rust
230-
before painting the enabled channel.
231-
232-
**Also deferred:** load / import / export commands (`LoadTextureCommand`,
161+
Map texturing (terrain texture, diffuse, shading, grass, DNTS), via native `Gfx`
162+
(texture creation, render-to-texture, readback, image save, blits, shaders).
163+
164+
**Status:** in stable for review. Rust owns the paint workflow — paint, cache,
165+
stroke close, undo/redo (`nativeCommandsOnly`); `Gfx` render-to-texture runs
166+
directly from the command path (no `DrawScreen` deferral). Optional shading
167+
textures created by the editor are mirrored lazily before painting. Feature lives
168+
under `native/src/sbc/textures/{commands,model}`.
169+
170+
**Deferred:** load / import / export commands (`LoadTextureCommand`,
233171
`ImportDiffuseCommand`, `LoadGrassMapCommand`, `LoadMetalMapCommand`,
234172
`ImportShadingImageCommand`, `ExportDiffuseCommand`,
235173
`ExportShadingTexturesCommand`, `ExportGrassCommand`, `ExportMetalCommand`).

‎docs/porting/review-queue.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Each row has: link to the Rust file, the Lua file it replaces, a one-line descri
1111

1212
| # | Item | Files | State | What to test |
1313
|--:|------|-------|-------|--------------|
14-
| 1 | Slice 1 — Terrain brushes (shape / level / smooth / metal) ported to Rust, flipped to Rust-only. Also lands the feature-agnostic IO-worker shell (boxed-job seam) + in-engine test harness as common infra. | Rust: [terrain_shape_modify_command.rs](../../native/src/sbc/commands/heightmap/terrain_shape_modify_command.rs), [terrain_level_command.rs](../../native/src/sbc/commands/heightmap/terrain_level_command.rs), [terrain_smooth_command.rs](../../native/src/sbc/commands/heightmap/terrain_smooth_command.rs), [terrain_metal_command.rs](../../native/src/sbc/commands/heightmap/terrain_metal_command.rs), [set_heightmap_brush_command.rs](../../native/src/sbc/commands/set_heightmap_brush_command.rs), [brush_modify.rs](../../native/src/sbc/commands/heightmap/brush_modify.rs), [brush_filter_generator.rs](../../native/src/sbc/commands/heightmap/brush_filter_generator.rs), [terrain_manager.rs](../../native/src/sbc/commands/heightmap/terrain_manager.rs), [io/io_api.rs](../../native/src/sbc/io/io_api.rs), [io/worker.rs](../../native/src/sbc/io/worker.rs), [tests/tests_api.rs](../../native/src/sbc/tests/tests_api.rs). Lua: [command_manager.lua](../../scen_edit/command/command_manager.lua) (allowlist), [widget.lua](../../scen_edit/widget.lua) (poll driver) — replacing the Lua brushes in [abstract_terrain_modify_command.lua](../../scen_edit/command/abstract_terrain_modify_command.lua) + the four `terrain_*_command.lua`. | review | 1. Raise/lower brush: stamp flat ground — terrain rises and **visibly moves**; undo restores; redo re-applies. 2. Level: set a target height, stamp — area flattens; undo/redo. 3. Smooth: stamp rough terrain — smooths; undo/redo. 4. Metal: stamp — metal spots appear; undo/redo. 5. **Drag** one continuous stroke, undo once — the whole stroke reverts as a unit, and the infolog has **no** `streaming_commands` errors. Automated: `cd tools/smoke && uv run pytest` → 9 passed. |
14+
| 2 | **Heightmap load + image import/export** (slice 2). Whole-map load + 16-bit-greyscale PNG import/export on the background IO worker, all Rust-only (`nativeCommandsOnly`). Load reads the `.data` file (LE `f32`) by **path** — the path crosses the bridge, not the bytes. Import/export replace the spring-launcher round-trip. Lands the command IO seam: `Context::submit_io` → drained into the worker after execute → `SBC::drain_io`; `wait_for_io`/`wait_for_file` test helpers. | New: [`heightmap/commands/`](../../native/src/sbc/heightmap/commands/)`{load_map,import_heightmap,export_heightmap}_command.rs`, [`heightmap/model/heightmap_io.rs`](../../native/src/sbc/heightmap/model/heightmap_io.rs). Seam: [context.rs](../../native/src/sbc/command_system/context.rs), [sbc.rs](../../native/src/sbc/sbc.rs), [tests_api.rs](../../native/src/sbc/tests/tests_api.rs), `image` dep. Lua: [load_map_command.lua](../../scen_edit/command/load_map_command.lua) (path, not bytes), [load_project_command_widget.lua](../../scen_edit/command/project/load_project_command_widget.lua) (passes the path), flip in [command_manager.lua](../../scen_edit/command/command_manager.lua). | review | `cd tools/smoke && uv run pytest` → `heightmap_load` + `heightmap_import` + `heightmap_roundtrip` pass (load applies heights from a written `.data`; import/export at 16-bit tolerance). In-editor (now Rust-only): **load a saved project** → terrain restores; import a PNG → terrain matches; export → 16-bit PNG that re-imports. Export reads **live** engine heights (Lua read a saved file). |
1515

1616
## States
1717

‎libs_sb/json.lua‎

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -322,17 +322,55 @@ function decode_scanWhitespace(s,startPos)
322322
return startPos
323323
end
324324

325-
--- Encodes a string to be JSON-compatible.
326-
-- This just involves back-quoting inverted commas, back-quotes and newlines, I think ;-)
327-
-- @param s The string to return as a JSON encoded (i.e. backquoted string)
328-
-- @return The string appropriately escaped.
325+
--- Encodes a Lua (byte) string into a valid JSON string body.
326+
-- `"`, `\` and control chars are escaped; valid UTF-8 is passed through so text
327+
-- round-trips; any byte that is not part of a valid UTF-8 sequence (i.e. binary
328+
-- data) is escaped as \u00XX so the output is always valid JSON.
329+
local jsonShortEscapes = {
330+
[0x22] = '\\"', [0x5C] = '\\\\',
331+
[0x08] = '\\b', [0x09] = '\\t', [0x0A] = '\\n', [0x0C] = '\\f', [0x0D] = '\\r',
332+
}
333+
-- Expected continuation bytes for a UTF-8 lead byte (0 = not a valid lead).
334+
local function utf8SeqLen(b)
335+
if b >= 0xF0 and b <= 0xF4 then return 4 end
336+
if b >= 0xE0 and b <= 0xEF then return 3 end
337+
if b >= 0xC2 and b <= 0xDF then return 2 end
338+
return 0
339+
end
329340
function encodeString(s)
330-
s = string.gsub(s,'\\','\\\\')
331-
s = string.gsub(s,'"','\\"')
332-
s = string.gsub(s,"'","\\'")
333-
s = string.gsub(s,'\n','\\n')
334-
s = string.gsub(s,'\t','\\t')
335-
return s
341+
local parts = {}
342+
local i, n = 1, #s
343+
while i <= n do
344+
local b = string.byte(s, i)
345+
if jsonShortEscapes[b] then
346+
parts[#parts + 1] = jsonShortEscapes[b]
347+
i = i + 1
348+
elseif b < 0x20 then
349+
parts[#parts + 1] = string.format('\\u%04x', b)
350+
i = i + 1
351+
elseif b < 0x80 then
352+
parts[#parts + 1] = string.char(b)
353+
i = i + 1
354+
else
355+
local seqLen = utf8SeqLen(b)
356+
local valid = seqLen > 0 and i + seqLen - 1 <= n
357+
for k = 1, (valid and seqLen - 1 or 0) do
358+
local cont = string.byte(s, i + k)
359+
if cont < 0x80 or cont > 0xBF then
360+
valid = false
361+
break
362+
end
363+
end
364+
if valid then
365+
parts[#parts + 1] = string.sub(s, i, i + seqLen - 1)
366+
i = i + seqLen
367+
else
368+
parts[#parts + 1] = string.format('\\u%04x', b)
369+
i = i + 1
370+
end
371+
end
372+
end
373+
return table.concat(parts)
336374
end
337375

338376
-- Determines whether the given Lua type is an array or a table / dictionary.

0 commit comments

Comments
 (0)