4. Port a behaviour to Rust
Porting means writing Rust code that does what the original functions do. Start only when the behaviour is traced (Trace) and when Un-Banished can read every resource it needs (Decode a format). If you notice that you are inventing behaviour, stop and trace it.
Where the code goes
| Kind of code | Where | Rules |
|---|---|---|
| Exact maths, random numbers, noise, game time | crates/core | No game rules, no data files |
| Map generation, terrain mesh, ground queries | crates/terrain | Exact; tested against the emulator |
| Path finding | crates/path | Exact; tested against the emulator |
| Game rules, the simulation, generators | crates/game | No graphics, windows or sound; deterministic; unit and parity tests |
| Reading resources and saves | crates/formats | No dependencies |
| Interface elements, controls, layout, input | crates/ui | General code only; no rules of the game |
| Engine loops, game controllers, input routing | apps/game/src/ (loops.rs, menu.rs, gameplay/) | Follow the original's controllers |
| Dialog controllers (the game's windows) | apps/game/src/ui/ | One module for each original controller (toolbar.rs, town_hall_ui.rs, …) |
| Rendering | apps/game/src/gfx/ (render.rs, scene_gfx.rs, models.rs, *.wgsl) | Must also run on WebGL 2 (Port a shader) |
Keep the original's shape
- Use the original's names. Name modules and types after the original classes and resources (
ToolbarItem,GameplayLoop). - Name the source. Write the doc comment in plain English for readers of the API documentation, and end it with one line that names the original function. Offsets, vtables and
DAT_constants go in an ordinary//comment, where the tracing tools still find them:rustThe doc-comment style explains the rule, and// The speed level is Game +0x34. /// The next game speed: 1, 2, 5, then 10. /// /// Original: `FUN_1400be680`. fn next_speed(level: usize) -> usize { … }python3 tools/triage/doc_comments.py checkfinds doc lines that break it. - Keep the data flow. If the original builds a dialog from
Dialog/X.rscand fills it from resources, do the same. Do not build an interface in code that the game ships as a resource. - Keep the constants. Write each constant with its source address. Do not round or "tidy" numbers.
- Mark differences. A deliberate difference (a platform limit, WebGL 2, one thread in the browser instead of several) gets a comment with
Deviation:and the reason. After the change, runpython3 tools/triage/deviations.pyto update DEVIATIONS.md.
Exact arithmetic
The details that drift are the ones that cause differences later:
- Float order. Float addition and multiplication are not associative. Write the terms in the original's order.
- Runtime maths. In simulation code, use
banished_core::crt(sinf,cosf,acosf,atan2f) andbanished_core::math(normalise, heading,Transform), not Rust'sf32methods. Rust'sf32::sincan give a different last bit. - Game time.
System::Timeis integer microseconds. Convert withscheduler::secondsandscheduler::ticks. - Order of iteration. The order in which objects are visited, the order of calls to the game's
Random, and the instability of the C runtime'sqsortcan all be observed in the results.behaviour::ai::crt_qsort_byreproducesqsort. - No hidden non-determinism. In
crates/game, do not use the clock, do not let the order of aHashMapreach the output, and always use the portedRandom.
The engine loops
apps/game/src/loops.rs holds the engine's loop model. MainLoop runs one loop at a time: the splash screen, the menu, the loader, the game. LoaderLoop and DialogLoaderLoop load the next loop in small steps, because the browser cannot block. Fades run at 4 units per second.
Each loop owns a Screen, which holds its dialogs:
| Method | What it does |
|---|---|
add_dialog(ui, "Dialog/X.rsc:resource") | Instantiate a packaged dialog |
add_modal(ui, reference) | The same, as a modal dialog |
remove_dialog(ui, index) | Remove a dialog |
find(ui, dialog, name) | Find an element of a dialog by its name |
layout(ui, size) | Lay out every dialog |
The game itself is GameplayLoop in gameplay.rs. In the original, FUN_14009d530 builds it and FUN_14009d0d0 installs its interface: the toolbar, the event notifications and the autosave.
Packaged dialogs
The game's windows are resources (Dialog/*.rsc). To port one:
- Look at the resource: its element names, ids, templates and alignment.bash
cargo run -p banished-formats --example dialog_info -- game-data/original/WinData \ "Dialog/Toolbar.rsc:resource" - Instantiate it through the screen. Find its elements by name, as the original does (
screen.find(ui, dialog, "toolbarContent")), never by their position in the tree. - Use the helpers of
banished_ui::Ui(set_text,set_table_child,set_images,checked,set_checked, …). If a helper is missing, add a general one tocrates/ui/src/controls.rs, with a test incrates/ui/tests/controls.rs. - Strings come from string tables (
UI::String(table, key)); sprites come from sprite sheets (ui.res.sprite(sheet, name)). - Handle events: drain them with
drain_eventsand dispatch on the original element ids and event codes that the trace recorded. Note thatbanished_ui::pointer_buttonreturns the click event; it does not queue it.
Work in small slices
- Read the trace document again. List the functions to port and their addresses.
- Make the smallest piece work from end to end first, for example "the toolbar's first level is visible". Then add the rest.
- Build often:
cargo build --release -p banished-app, or-p banished-uifor a crate. - Verify (Verify), then document and commit (Document and land), for each slice, not only at the end.
Rules for the browser
The same code runs in the browser. So:
- no
std::threadin code that the browser runs; - no blocking file access;
- use
web_time::Instantinstead ofstd::time::Instant.