2. Emulate for exact numbers
Some behaviour must match the original to the last bit: the random number generator, the noise, map generation, the terrain mesh, ground queries, name generation and much of the simulation's maths. Reading the code is not enough for this. One wrong rounding step changes the map.
So Un-Banished runs the original functions in an emulator, records their output for many inputs, and tests the Rust port against that output. This method proved the random numbers, the Perlin noise, the terrain generator, the terrain's surface image, the tile mesh, the ground ray cast, the cell types and the start point.
The harness
tools/emu/pe_emu.py runs code from the original programs with Unicorn, a CPU emulator. It loads Application-steam-x64.exe and Runtime-steam-x64.dll at their normal addresses, links the program's imports to the DLL's exports, replaces the operating system's functions with stubs, and replaces the engine's memory allocator with a simple heap. The emulator harness explains how it works.
Set it up once:
python3 -m venv .venv && .venv/bin/pip install unicorn pefileThe main calls:
| Call | What it does |
|---|---|
Emu(exe, dll) | Load both programs |
malloc(size) | Allocate memory in the emulator |
write(addr, bytes), read(addr, n) | Write and read memory |
u32, i32, u64, f32s | Read typed values |
call(addr, *args) | Call a function (Windows x64 convention); returns rax |
call_f32(addr, *args) | Call a function that returns a float; returns xmm0 |
hook_function(addr, name, handler) | Replace a function with your own Python code |
Pass a float argument as F32(value), so that it goes into an xmm register.
Steps
- Trace the function first (Trace). You need to know its inputs: the layout of
this, the global values it reads (for example the game object at0x1401cbdd8), and every function it calls that uses the operating system, threads, files, graphics or sound. Those must be replaced with hooks. - Write a generator,
tools/emu/<topic>.py. Useterrain_query.pyorstart_point.pyas a model.- Start with a docstring that says how to run it and which files it writes.
- Build the smallest set of objects the function needs in emulator memory, with
mallocandstruct.packat the offsets you traced. - Call the function for many inputs: every cell, a grid of random positions, every seed in a range. Include the edge cases: the map border, zero and negative values.
- Write the raw output, little-endian, to
game-data/derived/emu/<topic>/. Git ignores this folder. Never write the output to a tracked folder.
bash.venv/bin/python tools/emu/<topic>.py game-data/derived/emu/<topic> - Port the function into the right crate (Port). Copy the original's arithmetic exactly:
f32orf64, truncation or rounding, the order of operations (float addition is not associative), wrapping integer arithmetic, signed shifts. The disassembly shows these details. The pseudocode often hides them. - Write a parity test in the crate's
tests/folder.crates/game/tests/terrain_emu.rsis a model.- Load the generator's output.
- If the folder does not exist, print a message and return. Other computers do not have it.
- Compare the bits exactly (
f32::to_bits), not with a tolerance. - On a failure, report the first index that differs and how many differ.
bashcargo test --release -p banished-game --test terrain_emu - If the result is not exact, divide the problem. Emulate the inner functions on their own, and compare each stage. For example,
terrain_stages.pywrites the height grid after each of the five generation stages.
Random-draw parity
Many ported functions draw numbers from the game's random number generator. The draws must come in the same order and the same number as in the original, or every later number differs.
- Record the original's draws in the emulator: create
RngTrace(emu)fromtools/emu/rng_trace.pybeforeemu.call, then write the trace. - Record the port's draws with
banished_core::random::trace:rustuse banished_core::random::trace; trace::start(); // ... call the port with the same inputs ... let draws = trace::take(); trace::write(std::path::Path::new("port.rng"), &draws)?; - Compare the two:bashIt prints the first draw that differs, with the original's function and offset next to the Rust file and line, for example
python3 tools/triage/rngdiff.py orig.rng port.rngFUN_1400a9d50+0x216 | crates/game/src/people/names.rs:79.
crates/game/examples/rng_trace_names.rs is a complete example. It traces the names of three citizens, and all 53 draws match.
Common problems
- "Unmapped memory access." A pointer field that you did not fill. The harness reports the address. Find in the disassembly which offset of which object the pointer came from.
- Thread-local storage. Functions that use it need
GS_BASE. The harness sets it up. - Threads and Direct3D. Functions that start threads or call Direct3D must be hooked.
- Very long functions, such as the river builder
FUN_1400d4fd0. Passtimeout_insnsand emulate the whole function. Then port it piece by piece and compare stage outputs. - Record the proof. Write in the topic document which generator and which inputs proved parity. If parity against real saves is not 100 %, give the rate, for example "99.95 % against allportez.sav".