rusting_engine 2.5.7

Vulkan 3D game engine with GPU-accelerated physics for massive physics-heavy scenes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
# Missing in RustingEngine

Things Rusting Raft needed that RustingEngine does not have yet (checked against
RustingEngine 2.4.1, 10e9702). Each entry says what the game wrote instead, so the
workaround can go once the engine has the feature.

## Features

### Raycasts before the first physics step

`GameScene::raycast` reads `PhysicsWorld` bodies, which only `step_cpu_physics` fills
(`set_bodies`, `src/runtime/cpu_physics.rs:1948`). On the first fixed tick
(`fixed_tick` 0) the game's tick sees no collider at all. Child colliders (the island
ground) also take the `GlobalTransform` propagated after the previous frame
(`src/runtime/cpu_physics.rs:2196`), and the first windowed frame runs several steps
(up to 8) before any propagation, so their bodies sit at unpropagated poses until a step
in a later frame: a grid sampled at `fixed_tick` 1 found 484 of 8660 heights.
Scenarios do not show it, since their tick 0 is a zero-delta frame that propagates first.

- Instead: `tick` in `src/app.rs` builds `SceneGround` once `fixed_tick` is past the
  value it had in the first frame, and the session waits for it.
- Wanted: bodies built and posed from propagated `GlobalTransform`s when the scene loads,
  so rays hit from tick 0.

Status: done (this commit, test: `rays_hit_child_colliders_before_the_first_physics_step`).
`cpu_physics::pose_bodies` propagates transforms and builds the bodies when a project loads,
on `GameScene::load_scene` / `restart` (`forget_old_scene`) and after a code-reload restore.
Passed: `cargo fmt --all --check`, clippy default / `--no-default-features` /
`--features gpu-tests` with `-D warnings`, `cargo test --workspace`, and the locked
`--features gpu-tests` run.

### Hardened relay

2.4.0's `run_relay_with` (`src/net.rs:863`, `RelayLimits`) caps connections overall and
per address (IPv6 per /64), new connections per address per minute and the read rate per
connection, skips failed accepts, and gives each handshake its own thread and a deadline.
Still missing next to the game's relay: forwarding writes straight to each socket while
holding the one lock over every room (its `ponytail:` note, `src/net.rs:829`; `forward`,
`src/net.rs:1048`), so a peer that stops reading stalls forwarding, room opens and joins
in every room on the relay for up to 5 s (the write timeout) instead of filling its own
send queue; no cap on open rooms or on clients in a room; no separate limit on room opens
and join attempts; no idle timeout after the handshake; a sender over the rate is slowed,
not dropped.

- Instead: Rusting Raft ships its own std-only relay (`relay/`, `docs/relay.md`) with
  per-IP limits, join and room rate limits, a handshake deadline, per-connection
  bandwidth caps and send queues, and a systemd sandbox unit
  (`deploy/rusting-relay.service`). The game client talks to it with `net/` (`raft_net`),
  whose protocol differs from `NetSession`'s.
- Wanted: those limits in `run_relay`, so games can use `NetSession::host_room` /
  `join_room` directly.

Status: done (this commit, tests: `a_peer_that_stops_reading_stalls_only_itself`,
`a_relay_caps_rooms_clients_and_room_attempts`, `a_relay_drops_a_sender_over_its_rate`,
`a_relay_closes_a_connection_idle_after_its_handshake`,
`a_throttle_allows_one_seconds_burst_then_refuses`,
`a_client_flooding_a_slow_host_is_cut_off_not_the_room_or_others`). Each relay connection writes through
its own bounded queue and writer thread (`RelayPeer`), so nothing touches a socket under the
rooms lock; `RelayLimits` gains `max_rooms`, `max_clients_per_room`,
`room_attempts_per_minute` (per address, refused attempts count), `idle_timeout` and
`send_queue` (bytes); a sender over `bytes_per_second`, or a message larger than one second
of it, is dropped. Each client holds at most `send_queue / max_clients_per_room` of its host's queue; a client
over its share is dropped, not the host or other clients. Known limit: without a token, 16
addresses (16 connections each) can hold all 256 rooms; run a public relay with a token. Passed: `cargo fmt --all --check`,
clippy default / `--no-default-features` / `--features gpu-tests` with `-D warnings`,
`cargo test --workspace`, and the locked `--features gpu-tests` run.

### Scenario step for the mouse wheel

Scenario steps (`StepAction`, `src/scenario.rs:1046`) can move the pointer, click a
label and press actions, but cannot turn the mouse wheel. The game's long windows
(Workbench, Logbook) sit in an egui `ScrollArea` (`src/ui/windows.rs:75`), and `click`
only finds text that is visible, so rows below the fold (Wood Farm, Radio Station) cannot
be clicked by label.

- Instead: `tests/flow_05_farm.json` and `tests/flow_06_radio.json` drag the scroll bar:
  a `pointer` step on the bar, `press` `use` (bound to `MouseLeft`), more `pointer`
  steps, then `release`. This depends on the bar's pixel position.
- Wanted: a `{"scroll": [dx, dy]}` step, or a `click` that scrolls the target into view
  first.

Status: done (this commit, test: `scroll_steps_turn_the_wheel_over_an_egui_scroll_area`).
`StepAction::Scroll` records wheel lines in `RuntimeInput::record_scroll`; the headless egui
pass sends them as `Event::MouseWheel`, and the windowed runtime records `MouseWheel` events
too, so game code reads `RuntimeInput::scroll()`. Passed: `cargo fmt --all --check`, clippy
default / `--no-default-features` / `--features gpu-tests` with `-D warnings`,
`cargo test --workspace`, and the locked `--features gpu-tests` run.

### Cursor grab that follows mouse look and focus

The controller's look code (`player_look`, `src/runtime/player.rs:326`) frees the cursor
when mouse look goes off or Esc goes down, but grabs it again only on the next left click.
So after a window closes (`GameScene::set_mouse_look(.., true)`,
`src/project_runner.rs:872`) the camera does not turn until the player clicks. Losing focus
also keeps the grab: `RuntimeInput::record_focus` (`crates/rusting-core/src/input.rs:235`)
releases keys only, and `GameScene` has no call to grab or free the cursor.

- Instead: `cursor` in `src/app.rs` writes `RuntimeInput::set_cursor_captured` through
  `scene.world().resource_mut::<RuntimeInput>()` every frame: grabbed while mouse look is
  on, so a window closing or an Esc that opens nothing (placing a part) keeps the camera
  turning. Focus loss or the Windows key frees it until the next click, and that click
  does not fire the held tool. The engine's look code runs after the game's update, so
  an Esc the game used still frees the cursor for that one frame.
- Wanted: `GameScene::set_cursor_captured(bool)`, a grab when `set_mouse_look` turns
  look on while the window has focus, a release on focus loss, and a way for the game to
  keep an Esc it used (cancel placing a part) from freeing the cursor.

Status: done (this commit, test: `cursor_grab_follows_mouse_look_focus_and_the_game`).
`GameScene::set_cursor_captured` writes `RuntimeInput::set_cursor_captured`, which also marks
a grab asked for this frame; `player_look` skips its Esc release on that frame.
`set_mouse_look(.., true)` grabs at once while focused, and `RuntimeInput::record_focus(false)`
frees the cursor. Not done: keeping the regrab click from reaching game tools (game code can
check `cursor_captured()` before acting on a click). Passed: `cargo fmt --all --check`,
clippy default / `--no-default-features` / `--features gpu-tests` with `-D warnings`,
`cargo test --workspace`, and the locked `--features gpu-tests` run.

### Scenario step for a raw key

Scenario `press` and `tap` (`StepAction`, `src/scenario.rs:1046`) take a named action
only. A key that no action uses, such as the Windows key (`SuperLeft`), cannot be pressed
in a scenario.

- Instead: the unit test `cursor_follows_look_and_stays_free_after_focus_loss_until_a_click` in
  `src/app.rs` covers the Windows key; `tests/ui_cursor.json` covers focus loss.
- Wanted: a `{"key": "SuperLeft"}` step that presses one key code.

Status: done (this commit, test: `key_steps_tap_a_key_no_action_uses`). `StepAction::Key`
takes a winit `KeyCode` name, records the press before the tick's update and the release
before the next, like `tap`; an unknown name fails to parse. Passed: `cargo fmt --all --check`,
clippy default / `--no-default-features` / `--features gpu-tests` with `-D warnings`,
`cargo test --workspace`, and the locked `--features gpu-tests` run.

### Quaternion-safe animation blending

Clip sampling (`AnimationTrack::sample`, `lerp` in `src/runtime/animation.rs:265`) and
the crossfade (`mix`, `src/runtime/animation.rs:591`) lerp Orientation keys component by
component, with no sign check (the result is only normalised when written). Two keys for
the same rotation with opposite signs (q and -q) pass near zero in between, and the bone
collapses or spins. A
crossfade also keeps a track that only the old clip keys at full weight, so that bone
stays where the old clip left it.

- Instead: the hand-keyed "swim" and "tread" clips (`swim_clips` in
  `tools/scene/build_scene.py`) keep w <= 0 like the imported clips, add a second key at
  the same time wherever the sign flips, and key Position and Orientation on every bone.
  The crawl arms are keyed as a swing from rest with no twist (`stroke`), so a full
  stroke ends on the same key instead of the negated one. Every clip on a swimming human
  also gets rest keys for the bones it leaves alone, so the swim roll does not stay on
  after the fade into a clip that never keys the root.
- Wanted: nlerp or slerp with a shortest-path sign flip for Orientation in `sample` and
  `mix`, and a crossfade that eases tracks only one side has back to rest.

Status: done (this commit, tests: `orientation_blends_take_the_short_way_round`,
`a_crossfade_eases_tracks_the_new_clip_leaves_back_to_rest`). `sample` (Linear and Smooth),
`mix` and override layers nlerp Orientation after flipping the far key to the near sign.
`AnimationPlayer` records each Position / Rotation / Scale / Orientation value before it
first writes it, and a crossfade blends a track only the old clip keys toward that rest
value, ending on it. Tracks only the new clip keys still start at full weight. Passed:
`cargo fmt --all --check`, clippy default / `--no-default-features` / `--features gpu-tests`
with `-D warnings`, `cargo test --workspace`, and the locked `--features gpu-tests` run.

## Features for v2 (checked against RustingEngine 2.5.6)

What the v2 plan (`docs/superpowers/specs/2026-10-14-rusting-raft-v2-design.md`) needs.
E1 to E6 were planned in spec 4.3; E7 onward turned up while building it.

### E1. Open ocean in `rusting.water` (staged)

`rusting.water` is a fixed rectangle of three sines. It has no endless sea around the camera,
no Gerstner waves, no way to change the waves at runtime, and no game clock.

- Instead: nothing. The change is written and tested (the water tests pass, clippy is clean)
  and the game already uses it. It is uncommitted in `~/RustingEngine` on local branch
  `raft-v2-gaps` (made from the v2.5.6 tag), with a `CHANGELOG.md` `[Unreleased]` entry. Commit
  it there for v2.5.7.
- Wanted: `WaterBody` gains `waves: Vec<OceanWave>` (`direction`, `length`, `height`,
  `steepness`, with analytic normals), `ocean: bool` (the grid follows the active camera, its
  cells grow away from it, and bodies float everywhere) and `time: Option<f64>` (a game clock in
  place of the fixed tick's). `WaterBody::wave` returns the swayed Gerstner surface. `WaterBody`
  stops being `Copy`, and `wave` takes its time as `f64`.

Status: split into E1a (waves, clock, `f64` time) and E1b (`ocean`). The staged change on
`raft-v2-gaps` is not on this machine, so both are rebuilt here.
E1a: done (this commit, tests: `gerstner_waves_sharpen_crests_and_keep_time_in_f64`,
`a_water_clock_moves_the_mesh_in_place_of_the_fixed_tick`). `WaterBody` gains
`waves: Vec<OceanWave>` (`direction`, `length`, `height`, `steepness`; deep-water speed,
analytic normals) and `time: Option<f64>`; `WaterBody::clock` picks the seconds the mesh,
buoyancy and swimmers use. `wave` takes `f64` time, wraps phases in `f64`, and returns the
swayed surface over `(x, z)` (8 fixed-point steps back to the swayed start point). `WaterBody`
is no longer `Copy`. Passed: `cargo fmt --all --check`, clippy default /
`--no-default-features` / `--features gpu-tests` with `-D warnings`, `cargo test --workspace`,
and the locked `--features gpu-tests` run.
E1b: done (this commit, test: `an_ocean_follows_the_camera_and_floats_bodies_everywhere`).
`WaterBody::ocean` makes `contains` true everywhere (buoyancy and swimming), and `sync_water`
centers the grid on the active camera (same pick as `active_camera`, snapped to whole center
cells) with cells a quarter of the even size at the center growing to 1.75x at the rim.
Passed: `cargo fmt --all --check`, clippy default / `--no-default-features` /
`--features gpu-tests` with `-D warnings`, `cargo test --workspace`, and the locked
`--features gpu-tests` run.

### E2. Clouds on `rusting.procedural_sky`

- Instead: the sky has no clouds, so weather barely shows overhead.
- Wanted: `cloud_cover`, `cloud_darkness`, `cloud_speed` and a wind direction, with clouds that
  catch and dim the sun.
- Status: done (this commit, test: `clouds_grey_the_sky_and_hide_the_sun` (GPU) and
  `clouds_drift_with_the_wind_and_clamp_their_cover`). `ProceduralSky` gains `cloud_cover`,
  `cloud_darkness`, `cloud_speed` and `wind_direction`; the background pass draws an fbm sheet
  2 km up that drifts with `elapsed_seconds`, glows toward the sun and hides its disc. The sky
  bake (lighting, reflections) stays cloudless so it does not rebake as clouds move. Passed: fmt,
  clippy (default, no-default-features, gpu-tests), `cargo test --workspace`, gpu-tests under the
  lock. Looks on real hardware: needs hardware check.

### E3. Particles stopped by roofs

- Instead: rain falls through roofs and into cabins.
- Wanted: an emitter option that kills particles below a height field or inside
  `rusting.particle_blocker` boxes.
- Status: done (this commit, test: `blocked_rain_stops_at_roofs_and_the_ground`).
  `ParticleEmitter::blockers` kills CPU particles inside `ParticleBlocker { extents }` boxes
  (turned and scaled with the object) and below any `Heightfield`, in the fixed step and the
  editor preview. GPU simulated emitters ignore it. Passed: fmt, clippy (default,
  no-default-features, gpu-tests), `cargo test --workspace`, gpu-tests under the lock.

### E4. Global wetness

- Instead: only terrain can look wet (`set_terrain_look`). Decks, sails and rocks stay dry in
  rain.
- Wanted: one global `wetness` value from 0 to 1. Materials marked `wettable` get darker albedo
  and lower roughness as it rises.
- Status: done (this commit, test: `wetness_darkens_only_wettable_materials`). Scene files do
  not save `wettable` yet (needs a cooked format version); games set it in code.

### E5. GPU skinning (done)

The engine has had GPU skinning (`rendering::gpu_skinning`) since 2.5.0, so nothing is
needed here. What is still missing is E15.

### E6. Lens droplets post effect

- Wanted: a post effect for rain on the camera lens, with a strength from 0 to 1. It is for
  the first-person view in storms.
- Status: done (this commit, test: `lens_droplets_bend_the_image_and_follow_the_tick`).
  `rusting.color_grading` `lens_droplets`, drawn in the tone-map pass.

### E7. Viewmodel layer with its own FOV

A camera has only `projection`, `active`, `priority` and `viewport`. There is no render layer
and no second pass that clears depth.

- Instead: the held tool is a child of the camera, 0.45 m in front. It shares the world FOV and
  can poke into walls.
- Wanted: a render layer on `MeshRenderer`, and a camera that draws only that layer over the
  main view after clearing depth, with its own FOV (spec 5).

Status: split into E7a (renderer and components) and E7b (scene files, editor, schema).
E7a: done (this commit, tests: `viewmodel_meshes_draw_over_walls_with_their_own_fov` (GPU),
`viewmodels_extract_only_under_a_viewmodel_camera`). The layer is a `RenderLayer` component
(`World`, `Viewmodel`) next to `MeshRenderer`, since `MeshRenderer` literals are everywhere.
The camera side is `ViewmodelCamera { vertical_fov_radians }` on the active camera. There is
no second pass: viewmodel meshes scale clip-space xy to their FOV and squash their depth
toward the near plane, so they draw over walls in the same pass and keep true world lighting.
Frustum culling still uses the main camera's FOV.
E7b: done (this commit, test: `examples_load_through_the_scene_parser_and_registry_unchanged`).
Scene files, snapshots, the editor and the schema know `rusting.render_layer` and
`rusting.viewmodel_camera`.

### E8. Reading the resolved quality profile

`resolve_quality` runs inside the renderer. On an integrated GPU it picks Eco, under 4 GB of
VRAM it picks Balanced, and otherwise High. Game code only sees the requested
`RenderSettings::quality`.

- Instead: game-side content (ocean grid, foliage counts, rain particles, view distance) treats
  Auto like Medium.
- Wanted: a read-only `RenderSettings::resolved_quality`, or a `GameScene` getter.
- Status: done (this commit, test: `resolved_quality_follows_the_gpu_once_a_renderer_starts`).
  `GameScene::resolved_quality()`; the game runner now puts `RendererCapabilities` in the
  world, as the editor already did. It stays `Auto` before a renderer starts.

### E9. Shadow cascade count

`ShadowQuality` sets the map size and distance: Low 1024 and 30 m, Medium 2048 and 50 m,
High 4096 and 80 m. It does not set how many cascades there are.

- Wanted: a cascade count per setting. Spec 4.2 asks for 2 on Low, 3 on Medium and 4 on
  High/Ultra.
- Status: done (this commit, test: `shadow_settings_override_the_profile`).
  `shadow_settings` returns the cascade count: 2 on Low/Eco, 3 on Medium/Balanced and 4 on
  High. The atlas stays 2x2, so fewer cascades leave tiles unused at the same texel size.

### E10. Texture resolution cap

- Wanted: a setting that limits loaded textures to a maximum size or mip level. Spec 4.2 ships
  2K textures with 1K mips, and Low should load only the 1K level so it fits the iGPU's memory
  and bandwidth.
- Status: done (this commit, test: `max_texture_size_reuploads_textures_at_the_capped_size`,
  `texture_cap_halves_each_layer_until_it_fits`). `set_max_texture_size(1024)`; HDR textures
  keep full size.

### E11. Water waves on the GPU

`sync_water` rebuilds and re-uploads the whole water mesh every fixed tick (the `ponytail` note
in `water.rs` says so too). An ocean at resolution 96 has about 9,400 vertices, which means
about 0.6 MB and roughly 1 ms of CPU per tick. Storm seas need finer grids.

- Wanted: wave displacement in the vertex shader.
- Status: split into E11a (the vertex shader displaces a flat grid by `RenderWorld::water_waves`)
  and E11b (`sync_water` builds the flat grid once and extraction fills the waves each tick).
  - E11a Status: done (84f4f6b, test: `water_waves_lift_a_flat_grid_in_the_vertex_shader`).
  - E11b Status: done (this commit, tests: `gpu_waves_trace_the_surface_the_cpu_samples`,
    `a_water_clock_sets_the_waves_in_place_of_the_fixed_tick`).

### E12. Foam on wave crests

Foam is drawn only at the shore (`shore_foam`). Vertex colour cannot add it either, because it
multiplies the base colour and so cannot turn blue water white.

- Wanted: a `crest_foam` strength in the water shader, driven by how much the Gerstner sway
  pinches the surface (its Jacobian). Spec 4.1 asks for foam on crests and around the raft.
- Status: done (this commit, test: `crest_foam_whitens_where_steep_waves_pinch_the_surface`).

### E13. `rusting test` building once

`rusting test . --keep-going` runs `cargo build` again before every scenario.

- Instead: no source edits while the suite runs, since later scenarios would pick up the
  changes or fail.
- Wanted: build once and run every scenario with that binary. This is faster and keeps a laptop
  cooler.
- Status: done (this commit, test: `a_scenario_folder_runs_every_scenario_in_the_one_build`).

### E14. Mouse wheel input actions

`rusting.input_action` takes keys, `MouseLeft`/`MouseRight`/`MouseMiddle` and gamepad buttons
(`src/runtime/actions.rs:33`), but not the wheel.

- Instead: the 10-slot hotbar is picked with keys 1-0 only.
- Wanted: `WheelUp` / `WheelDown` as pressable inputs (one press per wheel line), or
  `GameScene::scroll_delta()`.
- Status: done (this commit, `GameScene::scroll_delta()`, test: `a_tick_function_reads_each_wheel_turn_once`).

### E15. Copies keep animation and skin

`copy_tree` (`src/project_runner.rs:4107`) copies the meshes, but the copy's Animation tracks
still point at the original's bones, so only the original animates.

- Instead: the first shark plays its clips. A second or third shark only sways
  (`src/view/movers.rs`, ONE_INSTANCE). Players use a fixed pool of 7 avatars in the scene.
- Wanted: `copy_tree` remaps Animation tracks and skin joints onto the copied bones, so a copy
  animates on its own.
- Status: done (this commit, test: `copies_of_copies_animate_their_own_bones`). Tracks and
  joints already resolve copied bones by path; a copy of a copy named them
  `"Shark 3/Shark 2/Tail"`, so path `Tail` missed. `copy_tree` now drops the old prefix.

### E16. Listing a node's children

`GameScene` has no way to list the children of a named node (only `SceneTree::children`
inside the engine), and `set_color` changes one node.

- Instead: the four coloured parts of a raft tile are written out in the game code
  (`TILE_PARTS` in `src/view/mod.rs`), so a damaged tile can be darkened part by part.
- Wanted: `GameScene::children(name) -> Vec<String>`, or `set_color_tree(name, tint)` that
  multiplies the colour of a node and every node under it.
- Status: done (this commit, test: `children_lists_the_named_parts_of_a_node`).
  `GameScene::children`; tint a tree with it plus `color`/`set_color`.

### E17. Foliage of a multi-part model

`Foliage` scatters copies of one object's mesh. The Kenney palms are two meshes (trunk and
leaves), so they cannot be foliage.

- Instead: palms are placed one by one at the sampled terrain height, each with a `sway`
  material (`tools/scene/build_scene.py`).
- Wanted: `Foliage` on a parent copies the whole subtree as one instanced group, or takes a
  list of meshes that share each copy's transform.
- Status: done (this commit, test: `foliage_on_a_parent_scatters_every_part_together`).
  Each mesh under a `Foliage` parent draws one instanced batch per part.

### E18. Foliage density per quality preset

`Foliage::count` is fixed; the quality profile does not thin it.

- Instead: the client stores each foliage's base count and rescales `Foliage::count` when the
  graphics preset changes (`src/app.rs`, `Graphics::foliage_share`: Low 0.3, Medium 0.6,
  High/Ultra 1.0).
- Wanted: a per-profile foliage share (and far impostors on Ultra) in the engine.
- Status: done (this commit, test: `quality_profiles_thin_foliage_from_the_first_copies`).
  `foliage_share` (Eco 0.3, Balanced 0.6, High 1.0) thins every scatter at extraction. There
  is no Ultra profile, so far impostors are left out; `rusting asset bake-foliage` cards cover them.

### E19. Heightfield from an image

`Heightfield.heights` is inline `Vec<Vec<f32>>` in the scene JSON. The 14 island terrains
add about 160k samples (~45k lines) and 0.8 MB to `scenes/main.rscene`.

- Instead: the generator writes the samples inline.
- Wanted: a heightfield that reads a 16-bit PNG or raw file under `assets/` with a height
  scale, like the textures do.
- Status: done (this commit, test: `heightfields_read_their_heights_from_an_image_under_the_scene`).
  `Heightfield.image` (relative to the scene) and `height_scale`; saved scenes drop the samples.

### E20. World pose of a named bone

`GameScene` has no `bone_world(object, bone)`. `skinning::joint_matrices` gives skin
matrices, not a world pose.

- Instead: the octopus holds a player at a fixed offset from its body. (Finding the bone with
  `scene.world()` plus `scene_tree::find_path` and combining its parents' transforms by hand
  would work but reaches into engine internals.)
- Wanted: `GameScene::bone_world(object, bone) -> Option<[f32; 16]>` (or position and
  rotation) after this frame's animation.
- Status: done (this commit, test: `bone_world_gives_a_named_bones_pose_in_the_world`).

### E21. Global wind for foliage

Foliage and `sway` materials lean only inside force fields (`field_pushes` skips copies
outside a field), and a Wind force field also pushes every GPU particle
(`gpu_particles.comp:157`).

- Instead: one scene-wide `wind` sensor box (2400 x 300 x 2400 m) with a weak Wind field
  (0.5 m/s²), so rain, smoke and fire are pushed too; higher `sway` values make up for it.
- Wanted: a scene-level wind (direction, strength, gusts) that sways foliage and materials
  without being a force field, with a per-emitter wind factor for particles.
- Status: done (this commit, test: `scene_wind_pushes_particles_by_their_wind_factor`,
  `scene_wind_reaches_the_lean_and_gpu_particles_but_not_bodies`,
  `swaying_materials_bend_plain_meshes_in_wind_fields`). `rusting.wind` and
  `ParticleEmitter::wind_factor`.

## Bugs seen

- The frame cap (`FramePacer::request_next_frame`, `src/rendering/frame_pacer.rs:77`)
  sets `next_frame = now + interval` from the moment the loop wakes, so each late wake
  from `ControlFlow::WaitUntil` adds to the period and the rate settles below the cap.
  `next_frame += interval` (back to `now` only when far behind) holds the cap. The
  game's Max FPS setting (`GameScene::set_max_fps`) inherits this.
  Status: already present (`src/rendering/frame_pacer.rs:96`, `FramePacer::due` steps
  `next_frame += interval`; test `the_limiter_holds_its_rate_through_late_wakes_and_uneven_frames`).
- `FrameTime::real_delta` is documented as real time since the last frame, but
  `RuntimeApp::update` (`src/runtime/mod.rs:686`) clamps it to one fixed step while the
  app is overloaded, so a frame counter built on it never reads below 60. The game's FPS
  counter (`src/ui/mod.rs`) reads `Instant` instead.
  Status: done (this commit, test: `fixed_steps_slower_than_real_time_fall_back_to_slow_motion`
  asserts `real_delta` 50 ms while `delta` is clamped to 1 ms). `App::update` clamps only the
  clock; replays keep the real delta in `ReplayFrame::real_nanos`. Passed: `cargo fmt --all
  --check`, clippy default / `--no-default-features` / `--features gpu-tests` with `-D warnings`,
  `cargo test --workspace`, and the locked `--features gpu-tests` run.