From cdfee2a12511b05a2a7f67bc67ca3a818e140feb Mon Sep 17 00:00:00 2001 From: silver2127 <52584484+silver2127@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:20:03 -0400 Subject: [PATCH 01/65] minimap: a native-rendered in-game minimap for big maps (minimap=1, off by default) The Workshop minimap cannot work past 50 km or 5,000 lines, so this one is new. A GUI-only game script (embedded in the DLL, written to res/config/game_script at start and removed when off) owns the window, legend, markers and camera circle. An ImageView token "bigmap:minimap?..." is intercepted at ImageView::SetImage(string); the plugin renders that world rectangle from CTerrain::BaseGetVertices with the engine's colour ramp and hillshade and uploads it as RGBA through ImageView::SetImage(raw). Twelve sites are byte-verified before anything is hooked. - Roads, tracks and stations are drawn natively from a one-time network payload (anti-aliased Hermite strokes), cached by id; layer and company toggles re-send only the header. - Multiplayer companies mode: owners map to the lobby roster and take the lobby chip colours. - Industry types with cargo icons: from production, else the mod's own construction file, else the stock table, else an icon that exists (the plain map_town/map_industry names are not files; ui.zip ships only @2x). - UI units measured at run time (setMinimumSize is UI units x scale; AbsoluteLayout, getContentRect and getMousePos are screen pixels). tools/test_minimap.py: parser, renderer against an engine layout model, accessor, detour, network drawing, thunk, script sync, installer against the Steam exe, and the Lua script under a mocked UI and world. Co-Authored-By: Claude Opus 5 (1M context) --- build.bat | 2 + cfg/tpf2_bigmap.cfg | 10 + docs/minimap-native-preview.md | 599 +++++++++++++ docs/minimap.md | 165 ++++ mod/minimap/bigmap_minimap.lua | 1475 ++++++++++++++++++++++++++++++++ src/bigmap.cpp | 10 + src/minimap.h | 935 ++++++++++++++++++++ tools/embed_lua.ps1 | 41 + tools/test_minimap.py | 1245 +++++++++++++++++++++++++++ 9 files changed, 4482 insertions(+) create mode 100644 docs/minimap-native-preview.md create mode 100644 docs/minimap.md create mode 100644 mod/minimap/bigmap_minimap.lua create mode 100644 src/minimap.h create mode 100644 tools/embed_lua.ps1 create mode 100644 tools/test_minimap.py diff --git a/build.bat b/build.bat index bc261a6..e79c18a 100644 --- a/build.bat +++ b/build.bat @@ -95,6 +95,8 @@ if /i "%1"=="-codec-bench" ( exit /b ) +REM The minimap game script travels inside the DLL (src\minimap.h writes it out). +powershell -NoProfile -ExecutionPolicy Bypass -File tools\embed_lua.ps1 -In mod\minimap\bigmap_minimap.lua -Out out\minimap_lua.inc -Name kMinimapLua || exit /b 1 cl /nologo /O2 /MT /W3 /EHsc /c src\bigmap.cpp /Fo:out\bigmap.obj || exit /b 1 link /nologo /DLL /MAP:out\tpf2_bigmap.map /OUT:out\tpf2_bigmap.dll out\bigmap.obj || exit /b 1 echo BUILD BIGMAP OK diff --git a/cfg/tpf2_bigmap.cfg b/cfg/tpf2_bigmap.cfg index 7dbc6e4..937f899 100644 --- a/cfg/tpf2_bigmap.cfg +++ b/cfg/tpf2_bigmap.cfg @@ -56,6 +56,16 @@ terrain_align_fast=1 # order are unchanged. Saved games load through another path. 0 = stock. instance_shrink=1 +# Steam 35924, experimental: an in-game minimap that works on the largest maps. +# Adds a map button to the main toolbar through a GUI-only game script that the +# plugin writes to res\config\game_script\bigmap_minimap.lua (and deletes again +# when this is 0 or the plugin is uninstalled). The terrain picture is rendered +# natively and handed to the game as a texture, so its cost is the picture's +# pixel count, not the map size: no widget per pixel and no line limit. Towns +# and industries are clickable; click the map to move the camera. Pure UI: it +# cannot affect the simulation, saves or multiplayer. 0 = no minimap. +minimap=0 + # enabled=0 stops the host loading the DLL at all (checked before LoadLibrary, # so a disabled plugin's DllMain never runs). enabled=1 diff --git a/docs/minimap-native-preview.md b/docs/minimap-native-preview.md new file mode 100644 index 0000000..3354a0e --- /dev/null +++ b/docs/minimap-native-preview.md @@ -0,0 +1,599 @@ +# Native map preview (`UI::MapPreviewComp`) as an in-game minimap (Steam 35924) + +Read-only reverse engineering, September 16, 2026. The game was not launched, +attached to or patched, and nothing under the Steam directory was modified. Executable +SHA256 `782b904a8f7bbdac1f7a18528f1a5c778691e5aa3087c37c351bf6912585175c`. All +addresses are RVAs relative to `0x140000000`. + +Labels, as in `runtime-memory-audit.md`: + +- **MEASURED**: observed in a running game. **No in-game measurements were taken for + this document.** A few claims come from shipped data files (style sheets, climate + scripts). Those are marked **FILE**. +- **DERIVED**: read from decompiled or disassembled code. Correct if the reading is + correct, but not observed at run time. +- **GUESS**: an inference that the code does not pin down, such as a name, a meaning + or a performance figure. + +## Answers in brief + +1. `UI::MapPreviewComp` (vtable `3019868`, ctor `63a190`, 0x498 bytes) is a + `ContentView`. It turns a `Map` struct (the map generator's output) into an RGBA + `ImageView`, adds town and industry marker `ImageView`s on top, and shows a + "{length} x {width} km" text. Whatever `std::function` generator it is given fills + the `Map` on a worker thread. The component has no `TerrainToolkit` or generator + state of its own. [DERIVED] +2. `CreateTextureData` (`63b9a0`) produces the image. It is point-sampled from the + `Map`'s full-resolution float heightmap and is at most about 1024 px on its long + side. Land uses a climate height ramp, a hillshade and a tree tint. Water uses a + depth ramp. The image is uploaded as a fresh raw RGBA8 texture through + `ImageView::SetImage(int,int,int,const vector&,bool)` (`22b4350`) and + `tex_util::LoadRaw` (`25a0f40`). Neither `CreateTextureAsync` nor `ReloadTexture` is + on this path. A refresh builds a new `ImageView` and a new texture. [DERIVED] +3. Markers are child `ImageView` components in a `CFloatingLayout` over the image, at + normalized positions with y flipped. They are not drawn into the texture. [DERIVED] +4. **Partly.** It already runs in game: all six non-menu users sit in + `UI::CGameUI::CreateConstructionMenu` (`5a2900`), the in-game map-editor terrain + tools. There it is fed from the live world by `map_util::MakeMap` (`360ce0`). As + used there it does not scale. `MakeMap` materialises the whole 4 m heightmap as + floats: 1.0 GiB at 256² tiles, 4.0 GiB at 512², and a 32-bit overflow above 724² + tiles. The component keeps that `Map` alive and rebuilds its whole UI subtree on + every refresh. The reusable pieces are lower down: `ImageView::SetImage` (raw), + `IRenderContext::UpdateTexture`-like slot `0x218`, and `CTerrain::BaseGetVertices` + (`33d130`). [DERIVED] +5. Lua can reach none of this. `MapPreviewComp`, `TextureTriangleBuffer`, + `EmissionLayerComp` and `DebugTextureView` have no sol usertype. The Lua + `ImageView` usertype has `new(string)` and `setImage(string, bool)` only, and both + resolve a **file path**. No Lua entry point takes pixels or a `TextureId`. [DERIVED] +6. The cheapest native height reader is `CTerrain::BaseGetVertices(CVec2i tile)` + (`33d130`, the ECS `TerrainTileHeightmap`, 65x65 uint16). Heights in metres are + `v * CTerrain+0x34 + CTerrain+0x3c`. The water level is `CTerrain+0x40`. The tile + rectangle is at `*(CTerrain+0x18)`. [DERIVED] + +## 1. `UI::MapPreviewComp` + +### Identity + +| Item | Value | Evidence | Class | +|---|---|---|---| +| RTTI / vtable | `.?AVMapPreviewComp@UI@@`, vtable `3019868`, 40 slots | `vftables.csv`, `vtable_dump.csv` | DERIVED | +| Base class | `UI::ContentView`: vtable identical to `388b050` except slot 0 (dtor `63b780`) and slot 33 (`63bdb0`, per-frame update) | `vtable_dump.csv` | DERIVED | +| Source file | `src\Game\UI\Components\MapPreviewComp.cpp`; asserts in `63ca90` and `63b9a0` | strings `3019b10` | DERIVED | +| Object size | 0x498 (`operator new(0x498)` at `66d90f` before the ctor call at `66d941`, and in `5b71b0` and `52f1b0`) | disassembly | DERIVED | +| Constructor | `63a190(this, const terrain::ClimateRep& climates, CVec2i size, bool flag)` | decompile; the size is packed in `r8` (`66d933`) and passed to `22810d0`, which converts it to floats and calls component vslot `0xc0` (min size). `flag` goes to `22819c0` on the text box | DERIVED (meaning of `flag`: GUESS, text visibility) | +| Destructor | `63b780`: destroys the std::function at +0x458, then the impl (`63b610`) | decompile | DERIVED | + +### Field layout (the parts that matter) + +`MapPreviewComp` (0x498): + +| Offset | Field | Evidence | +|---|---|---| +| +0x018 | core pointer (base `CComponent`). `*(core+0x60)` is the `FileSystem` used by `Update` | `63bdb0` → `5f0ef0` | +| +0x440 | `ContentView*`, the centred progress/error slot. It is also item 2 of the floating layout | ctor, `63c6a0` | +| +0x448 | `Impl*` (0xa0 bytes, below) | ctor | +| +0x450 | `const terrain::ClimateRep*` | ctor, `63bdb0` | +| +0x458 | `std::function`, the marker-overlay factory; impl pointer at +0x490 | dtor, `66d96e`, `7c5100` | + +`Impl` (0xa0): + +| Offset | Field | Evidence | +|---|---|---| +| +0x00 | `std::string`, the progress text | `63c4f0` | +| +0x20 | `std::function&, UpdateProgress&, UpdatePreview&, Map*)>`, the generator (impl at +0x58) | `63c4f0`, `63a010` | +| +0x60 / +0x68 | `std::shared_ptr` (a new 0x168-byte `Map` for each generation, `63b830`) | ctor, `63b830`, `63b610` | +| +0x70 | async task (0x40 bytes, ctor `31c6d0`). Byte +0 is the cancel flag, byte +2 means "result pending", +8 is the progress, and an `exception_ptr` sits at +0x30 | `63bdb0`, `31cb20`, `63c4f0` | +| +0x78 | `TextView*` ("main-menu" style) | ctor | +| +0x80 | `CFloatingLayout*`. Items 0 and 1 are `ContentView`s at (-2,-2); item 0 holds the image. Item 2 is `this+0x440` at (0.5, 0.5) | ctor, `63ca90`, `63c6a0` | +| +0x88 | `std::vector`, the RGBA pixel buffer. **It is kept after upload** | `63bdb0` passes `impl+0x88` to `Update` | + +### Who creates it and how it is fed + +| Creator | Call site | Feeds it with | Class | +|---|---|---|---| +| `UI::CMenuUI::CreatePageNewGame` (`66c2b0`) | `66d941`: `MapPreviewComp(*(CMenuUI+0x590), {512,512}, true)`, then the marker factory `7c5100(false,false)` is moved into +0x458 | `65b620` ("Generating map...") calls `63c4f0` with the New Game generator lambda (`644b30` → `63f200`, `do_call` `67d8c0` → `6568b0` → `35f480`, which reads `TerrainGeneratorDesc` and reports `UpdateProgress`) | DERIVED (roles of the individual combo boxes: GUESS) | +| `UI::ExportMapComp` (`52f1b0`) | `52fb3b`, size `DAT_144133390` = (512,512) in `.data` | `5315d0` ("Generating preview..."): `MakeMap(out, ?, ?, terrain, engine, heightmap=1, towns=0, industries=0, trees=0)` at `53163f`, then a generator that copies the ready `Map` (`52e9c0`) | DERIVED | +| `GenerateHeightmapComp` (`5b71b0`) | `5b7334` | lambda `5ba740` ("Generating map...") | DERIVED | +| `GenerateIndustriesComp` (`5bef50`) | `5bf15c` | lambda `5c11c0` ("Generating industries...") | DERIVED | +| `GenerateStreetsComp` (`5c4580`) | `5c4ce7` | `5c76e0` ("Generating streets...") | DERIVED | +| `GenerateTownsComp` (`5cbc80`) | `5cbe9a` | `5ce260` ("Generating towns...") | DERIVED | +| `ImportHeightmapComp` (`5e79c0`) | `5e7fed` | `5e95f0` ("Reading heightmap...") | DERIVED | + +The six map-editor components are constructed only by +`UI::CGameUI::CreateConstructionMenu` (`5a2900`, called from `CGameUI::CreateUI` +`569f00` at `56e8ca`). They are gated on CGameUI bytes `+0x50d`/`+0x50e`/`+0x50f`; +`ExportMapComp` needs `+0x50f`. [DERIVED; the flags' meaning (map-editor capabilities) +is a GUESS.] The in-game style sheet also styles the component: +`construction-menu.lua` has `!construct-editor-menu MapPreviewComp` (350x350) and +`!construct-menu MapPreviewComp` (600x600) [FILE]. + +**Public entry point.** `63c4f0(this, const std::function&, const +std::string& text)` (name GUESS: `StartGeneration`). It stores the generator and the +text. If no task is running, `63c6a0` resets the `Map` (`63b830`), places a progress +bar in layout item 2 and starts the task. The task binds the generator to the `Map*` +(`63a010`). If a task is already running, it sets the cancel flag and the restart +byte. [DERIVED] + +**Per-frame slot 33 (`63bdb0`).** It returns at once when `impl+0x70` is null. When +the task has finished and nothing is pending, it calls `Update` (`63ca90`) on the UI +thread and frees the task. [DERIVED] + +### `Map` (0x168; ctor `35bcb0`, filled by `MakeMap` `360ce0`) + +| Offset | Field | Evidence | Class | +|---|---|---|---| +| +0x00 / +0x04 | int tiles X / Y | `63ca90`, `360ce0` (from `CTerrain` `33d540`) | DERIVED | +| +0x08 | int `levels` (base; samples per tile = `1< heightmap`, `((X<` (16 B: int id, CVec3f pos) | `63ca90` tree loop | DERIVED | +| +0x118 | towns, 0x80 B each: +0x00 `CVec2f pos`, +0x08 name, +0x78 int existing-entity or -1 | `7c4560`, `35cf20` | DERIVED | +| +0x130 | industries, 0x38 B each: +0x00 `CVec2f pos`, +0x10 name, +0x30 int existing or -1 | `7c42b0` | DERIVED | +| +0x148 | `std::string` climate id | `63ca90` (`ResTypeRep` lookup) | DERIVED | + +The two 0x40-byte objects at +0x38 and +0xa8 and the vectors at +0x78, +0xe8 and ++0x100 were not identified (GUESS: assets and generator-internal lists). + +## 2. The texture + +### Size [DERIVED, `63ca90`] + +``` +hmW = (X << levels) + 1 hmH = (Y << levels) + 1 +pw = 1 << ceil(log2(X << levels)) ph = 1 << ceil(log2(Y << levels)) +fact = max(pw,ph) <= 1024 ? 1 : floor(max(pw,ph) / 1024 + 0.5) // assert texSizeFact > 0 +texW = pw / fact texH = ph / fact +UV max = (hmW / (texW*fact), hmH / (texH*fact)) -> ImageView::SetCustomTextureCoordinates (22b4300) +``` + +`fact` is also the **sampling stride**: texel (x,y) reads heightmap sample +(`fact*x`, `fact*y`) with no filtering. + +| Map (tiles) | levels | heightmap samples | texture | stride | metres per texel | +|---|---|---:|---|---:|---:| +| 114x228 | 6 | 7,297 x 14,593 | 512 x 1024 | 16 | 64 | +| 256x256 | 6 | 16,385² | 1024 x 1024 | 16 | 64 | +| 512x512 | 6 | 32,769² | 1024 x 1024 | 32 | 128 | + +(DERIVED, assuming levels = 6 and resolution 4 m, as in `runtime-memory-audit.md`.) + +### `CreateTextureData` (`63b9a0`) + +Full signature (string `30199b0`): +`CreateTextureData(int hmW, int hmH, const CVec3f& res, float waterLevel, const +vector& heights, int texW, int texH, int fact, const +terrain::PreviewColoring& pc, const std::function& +colorFn, vector& out, int nChannels)`, with the assert `nChannels == 4`. + +The row loop is `ThreadPool::LoopImpl` (`639420`), one row per item, with chunks of +`texH/64` rows. The body is `63aa90`. The UI thread blocks until the loop finishes. +[DERIVED] + +Before the call, `Update` zeroes `out` and adds 2 to byte R of the texel under every +`MapTree` (`63ca90`). Per texel with `h = heights[hmW*fact*y + fact*x]` [DERIVED]: + +- **Water (`h < waterLevel`):** + `t = clamp((h - wl) / (-100 - wl), 0, 1)`, + `rgb = lerp(pc.waterColor0, pc.waterColor1, t)`. No shading. +- **Land:** + - Normal from central differences on immediate neighbours (±1 sample, not ±`fact`): + `n = normalize(-(h[x+1]-h[x-1])*(0.5/res.x)*2, -(h[y+1]-h[y-1])*(0.5/res.y)*2, 1)`. + - Sun term `s = max(0, n·(-1,1,1)/√3)`. + - Ambient term `a = (3*n.z + Σ max(0, n·d_i)) / (3 + Σ d_i.z)` over 24 hemisphere + directions (3 rings at `θ=(r+0.5)/3·π/2`, 8 azimuths). + - Base colour `c = colorFn(h, x, y)`. + - Tree tint from the pre-accumulated byte `k`: `t = max(0,k-0.75)*0.75/(k+1)`. + - `rgb = lerp(c, pc.treeColor, t) * (a*pc.ambientColor + s*pc.sunColor) * 0.5`. +- Channels: R, G, B = `floor(v*255+0.5)`, A = 255. Format RGBA8 (4 channels). + +### `terrain::PreviewColoring` (climate `mapColoring`) [DERIVED from reader `36f090`, default ctor `36d810`] + +| Offset | Lua key | Default | +|---|---|---| +| +0x00..+0x38 | `std::variant>`, index byte at +0x38: `texture = {levels, fileName}` (0) or `levels = {{color, height}}` (1) | index 1: (93,112,66)@0 m, (64,78,50)@100 m, (242,235,220)@550 m | +| +0x40 | `waterColor0` | (100,135,158) | +| +0x4c | `waterColor1` | (60,80,91) | +| +0x58 | `ambientColor` | (204,230,255) | +| +0x64 | `sunColor` | (255,255,204) | +| +0x70 | `treeColor` | (24,36,16) | + +- The `levels` colour function (`63e1d0`) binary-searches the height and interpolates + linearly, clamping at both ends [DERIVED]. +- The `texture` variant loads `res/textures/` (must be `.tga`) and + `res/textures/terrain/noise.tga`, and samples through `63a800` [DERIVED, sampler not + decompiled]. +- Shipped climates [FILE]: `temperate.clima.lua` sets only `ambientColor`/`sunColor` + and so gets the default ramp. `dry.clima.lua` and `tropical.clima.lua` define + `levels`, and `tropical` also defines water colours. + +### Upload, and updating after creation [DERIVED] + +1. `Update` allocates an `ImageView` (0x488) and calls the raw-pixel ctor + `22b2f90(this, 4, texW, texH, const vector&, CVec2i frames=(-1,-1), int + numFrames=-1)` (`63d84a`). +2. The ctor calls `ImageView::SetImage(int nChannels, int w, int h, const + vector&, bool validate)` (`22b4350`, assert `size == 4 || size == 3`): + - It releases any previous texture (`22b4240`). + - It builds a `TextureCreateInfo` (`22b4160`, `25a0c60`). + - It calls `tex_util::LoadRaw(IRenderContext&, TextureFormat (RGB_8=2, RGBA_8=3), + w, h, const uint8*, const TextureCreateInfo&)` (`25a0f40`). That is + `IRenderContext` vslot `0x1f8` = `CreateRawTexture(const void*, TextureFormat, + CVec2i, const TextureCreateInfo&)`: GL `257c180`, Vulkan `25d29c0`. Vulkan + `vulkan_util::LoadRawTexture` (`25f3f00`) runs synchronously, and only the data + pointer (`vector.begin`) is read. + - It stores a 0x14-byte `Texture {TextureId id; int 0; int w; int h; int + channels}` at `ImageView+0x440`. + - The render context comes from `engine::GetGlobalGLContext()` (`daf90`, global + `0x4331bb8`), field `+0x38`. +3. `SetCustomTextureCoordinates` (`22b4300`), the style name `map-preview-image`, + and `ContentView::SetContent` (`2286020`) into layout item 0. +4. **After creation:** + - `MapPreviewComp` never updates the texture in place. Each generation creates a + new `ImageView`. + - Calling `ImageView::SetImage` again destroys and recreates the texture + (`22b4240` → `25a1700` → vslot `0x200`, destroy a `vector`). + - The render context does have an in-place sub-rectangle update: vslot **`0x218`**. + GL `25840d0` does `glPixelStorei(UNPACK_ALIGNMENT,1)`, `glBindTexture`, then + `glTexSubImage2D(GL_TEXTURE_2D, 0, off.x, off.y, size.x, size.y, , + , data)`. Vulkan `25dd130` does a staging upload through `25f6440` + (`CmdBegin`, `CmdLayoutTransition`, `CmdImageCopy`). + - The engine uses it in game for `engine::ColorMap::UpdateSubData` (`d8760`), + reached from `UI::EmissionLayerComp::UpdateTexture` (`52e050`). Call shape: + `rc->vtbl[0x218](rc, TextureId, CVec2i offset (r8), CVec2i size (r9), const + void* data ([rsp+0x20]))`. The pixel layout must match the texture's creation + format. + - `OpenGLRenderContext::ReloadTexture` (vslot `0x208`, GL `2583870`) takes a + `unique_ptr` and checks `textureData.reloadFn`. It serves + file-backed and streaming textures and is not needed here (GUESS). + `CreateTextureAsync` is not on this path. + +## 3. Markers [DERIVED] + +- The `std::function` at +0x458 is + `_Func_impl` (factory `7c5100(bool existingTowns, bool + existingIndustries)`, `do_call` `7c5170` → `7c4bb0`). +- `7c4bb0` builds one `CFloatingLayout` (`22ae540`). It adds + `IndustryMarkers(map+0x130)` (`7c42b0`) and `TownMarkers(map+0x118)` (`7c4560`), each + its own `CFloatingLayout`. +- Each marker is `new ImageView("ui/icons/main-menu/map_town.tga" | + "map_industry.tga")` (path ctor `22b30e0`): + - style name `MapPreviewComp::TownMarker` / `::IndustryMarker`; + - tooltip from the name (component vslot `0xd8`); + - style class `existing` when the flag is set and the entity id is ≥ 0; + - `CFloatingLayout::AddItem(marker, u, v)` (`22ae690`). +- Position mapping, lambda `do_call` `5c9a90` (COMDAT-shared): + +``` +origin = (-(X/2)*(1<vtbl[1](acc)`, then `CTerrain* = *(state+0x20)` and `ecs::Engine* = + *(state+0x28)`. +- In `CGameUI` the accessor is `*(CGameUI+0x450)` (`5a2900` passes `param_1[0x8a]`). + The accessor class was not identified. +- The in-game `ClimateRep` is `*(*(*(CGameUI+0x448)+0x150)+0xd8)` (`5a2900` → `5b71b0` + argument). + +### Why it does not scale as used + +| Cost | 114x228 | 256² | 512² | Evidence | Class | +|---|---:|---:|---:|---|---| +| `MakeMap` float heightmap `(64X+1)(64Y+1)*4` | 406 MiB | 1.00 GiB | 4.00 GiB | `360ce0`, `35d3e0` | DERIVED | +| ...kept by the component (`shared_ptr` at impl+0x60) until the next generation or destruction | same | same | same | `63b830`, `63b610` | DERIVED | +| RGBA pixel buffer kept at impl+0x88 | 2 MB | 4 MB | 4 MB | `63bdb0`, `63ca90` | DERIVED | +| Heightmap pass: 16- or 32-sample stride, one full-map pass per refresh | | | | `63aa90` | DERIVED | +| Per refresh: a new `ImageView` + texture + every marker `ImageView` | | | | `63ca90` | DERIVED | + +Further points [DERIVED unless marked]: + +- **The sample count is 32-bit.** `MakeMap` computes it with `imul edx, eax; movsxd` + (`360ec6`), so any map with `(64X+1)(64Y+1) > 2^31-1` (square maps above 724² tiles) + gets a wrapped, undersized vector. The fill lambda also indexes with int arithmetic. + GUESS: heap corruption. +- **Side finding.** `industry_util::CreateAndAddIndustry` (`92c1d0`) calls `MakeMap` + with `heightmap=1, towns=1, industries=1` at `92c295` (bytes at `92c26d..92c281`). + If that path runs when an industry spawns during play (GUESS; its caller `152e90` is + a `GameSim.cpp` "Connect industry" lambda), every industry creation on a 512² map + allocates a transient 4.0 GiB float heightmap. The spawn-decision lambda `152ab0` + asks for industries only. **Verify before trusting.** It matters for bigmap + regardless of the minimap. +- It has no camera rectangle, no roads, rails or water meshes, and no click handling. + The layout is the New Game widget: text, a progress bar during generation, and a + "Map generation failed" error slot (`63c0b0`). + +### Hard dependencies + +| Dependency | New-Game-only? | Notes | +|---|---|---| +| `terrain::ClimateRep&` | No: `CMenuUI+0x590` in the menu, `GameRes+0xd8` in game | only for `PreviewColoring` | +| `Map` producer (`std::function`) | No | any function that fills `Map*`. `TerrainToolkit` / `ScriptGenerator` live only in the New Game lambda (`67d8c0` → `6568b0` → `35f480`), not in the component | +| `FileSystem` (`core+0x60`) | No | only for the `texture` colouring variant | +| Style names `map-preview-image`, marker styles | FILE: marker styles live in `main-menu.lua`; `construction-menu.lua` styles the component | GUESS: whether `main-menu.lua` rules apply in game | +| Global `ThreadPool` (`23827c0`) | No | row loop and async task | + +### Reusable lower-level pieces + +| Piece | RVA | Use | +|---|---|---| +| `ImageView` raw ctor | `22b2f90` | native-owned image component | +| `ImageView::SetImage(raw)` | `22b4350` | (re)create the texture from RGBA bytes on an existing `ImageView` | +| `ImageView::SetCustomTextureCoordinates` | `22b4300` | crop padding | +| `ImageView::SetImage(string)` | `22b4610` | the only method Lua reaches; detour target | +| `Texture` struct at `ImageView+0x440` | | `TextureId` at +0 | +| `GetGlobalGLContext` global | `0x4331bb8` (+0x38 = `IRenderContext*`) | render context | +| `IRenderContext` vslot `0x218` | GL `25840d0`, VK `25dd130` | in-place sub-rectangle upload | +| `CreateTextureData` | `63b9a0` | reference algorithm. Callable in principle, but it needs game-ABI `std::function` and `vector` objects and a pre-sized buffer. Reimplementing it (section 2) is simpler | +| `map_util::MakeMap` with `heightmap=false` | `360ce0` | towns and industries as `Map` records | +| `UI::DebugTextureView` | ctor `22a88e0` (0x458), `TextureId` at +0x440 | draws any `TextureId`, but through a debug-only draw `249a9c0` (single caller) with scale, flip and threshold fields. GUESS: may not show RGBA faithfully. `ImageView` uses the common quad `249b030` (12 callers) | + +### Minimal native call sequences + +**A. Raw texture behind an `ImageView`** (recommended core) [DERIVED call shapes] + +Run on the UI thread, inside a call where the `ImageView` is known to be alive: + +``` +// create or replace +struct { uint8_t* b, *e, *c; } px = { buf, buf + W*H*4, buf + W*H*4 }; // only b is read +ImageView_SetImage_raw(iv /*rcx*/, 4, W, H, &px, true); // 22b4350 +// in-place region update later +TextureId id = (*(Texture**)(iv + 0x440))->id; +IRenderContext* rc = *(IRenderContext**)(*(void**)(base + 0x4331bb8) + 0x38); +rc->vtbl[0x218/8](rc, id, CVec2i{x,y} /*r8*/, CVec2i{w,h} /*r9*/, rgba /*[rsp+20]*/); +``` + +**B. The component itself with a reduced `Map`** [DERIVED; std::function ABI GUESS] + +``` +p = operator_new(0x498); // 2bf3a80 +MapPreviewComp_ctor(p, climateRep, CVec2i{512,512}, true); // 63a190 +move marker std::function from 7c5100(tmp,false,false) into p+0x458 +Map m: X,Y; levels=2; resolution=(64,64,zScale); offset; waterLevel; + heightmap (4X+1)(4Y+1) floats sampled natively (16 MB at 512²); + towns/industries from MakeMap(&tmp, ?, ?, terrain, engine, 0, 1, 1, 0) on the UI thread +StartGeneration(p, std::function{ copy m into Map* }, "…") // 63c4f0 +insert p into a layout (no Lua route); slot 33 does the rest +``` + +Keep `resolution.xy * (1<@UI@@`): +- Components and layouts: `AbsoluteLayout`, `AbstractSlider`, `Button`, + `CAbstractLayout`, `CBoxLayout`, `CComponent`, `CFloatingLayout`, `Chart`, + `CheckBox`, `ColorChooser`, `ColorChooserButton`, `ColorPicker`, `ComboBox`, + `ContentView`, `CProgressBar`, `CRendererComponent`, `CTextInputField`, + `DoubleSpinBox`, `ILayout`, `ILayoutItem`, `ImageView`, `LayoutBase`, + `LineRenderView`, `List`, `ScrollArea`, `Slider`, `Slider2`, `SpinBox`, + `StationGroupDisplayComp`, `Table`, `TabWidget`, `TextView`, `ToggleButton`, + `ToggleButtonGroup`, `Window`. +- Non-components: `CCore`, `CGameUI`, `CameraController`, `ViewManager`. +- Registration of `ImageView` is in `RegisterUsertypesGui1` (`18fb6e0`). + +**Not bound:** `MapPreviewComp`, `TextureTriangleBuffer`, `EmissionLayerComp`, +`DebugTextureView`. No metatable string exists for any of them. + +- `TextureTriangleBuffer` (`868680` `SetTexture(TextureId)`) is not a UI component. It + owns GPU vertex and index buffers (`867270`), and its users are + `brush_util::Brush::UpdateBrush` (`485550`) and + `track_render_util::AddOneWayTextureTriangles`. GUESS: world-space overlays. + +**Lua `ImageView` usertype** (`usertype_metatable` at `42cb270`): +- `"new"`: factory `18db550` → path ctor `22b30e0(this, path, 0, 0, -1, -1, -1)`. +- `"setImage"`: lambda `1863370` → `22b4610(this, std::string path, bool)`. That + resolves `tex_load::ResolveTex` (`259d4c0`, empty string becomes + `ui/missing_image.tga`) → `22b4700` → `TextureManager::AddRef(TextureDesc)` + (`2490820`). +- Two read-only properties with 7- and 9-character names that were not resolved. +- Base classes `CComponent`, `ILayoutItem`. + +The legacy `game.gui` table (`113f530`) exposes `imageView_create` (`1139200` → +`22b30e0`) and `imageView_setImage` (`112a770` → `22b4610`), which are also path-only. +**No Lua path accepts raw pixels or a `TextureId`.** The only Lua-reachable code that +touches a texture is the path string passed to `22b4610`. + +`LineRenderView::DoRender` (`61a9f0`) rebuilds a two-vertex-per-line array every frame +from the line vector at +0x458. No cap was found in it or in the start of the UI line +draw `24995d0`; where the reported ~5,000-line limit comes from was not established. + +## 6. Live world data for the native side + +### `CTerrain` fields [DERIVED: getters `33d270`, `33d280`, `33d330`, `33d340`, `33d540`, `33d560`, `33d770`, `33d790`, `33d7f0`] + +| Offset | Meaning | +|---|---| +| +0x08 | `ecs::Engine*` (used by `BaseGetVertices`) | +| +0x18 | tile grid header `{int x0, y0, W, H; cells* at +0x10}`, cells 0x28 bytes, tile entity id at cell+0 (-1 = none) | +| +0x28 | int base levels (6 → 65x65 per tile) | +| +0x2c..+0x37 | `CVec3f` base resolution (x, y metres per base sample; z height scale) | +| +0x38 | int high levels (8 → 257x257 cache) | +| +0x3c | float height offset | +| +0x40 | float **water level** (also `ITiledTerrain` slot 10, `156a30` / `34e1d0`) | +| +0x50 | component type index of `TerrainTileHeightmap` | + +- Map extent: `W*256 m x H*256 m`, tile rectangle `[x0, x0+W) x [y0, y0+H)`. +- World origin as the preview uses it: `(-(W/2)*256, -(H/2)*256)` [DERIVED from + `63ca90`/`7c4bb0`; GUESS for odd W or H, verify against a known position]. + +### Height readers + +| Reader | What it returns | Paging interaction | Class | +|---|---|---|---| +| **`CTerrain::BaseGetVertices(CVec2i tile)`** `33d130` | `const vector&`, 65x65 base heightmap from the ECS `TerrainTileHeightmap`. Lock-free component lookup (`engine+0x88[cti]`, dense +0x68 or paged +0x80). Asserts if the cell id is -1 | not paged by the plugin (the pager covers the 257² cache only, `terrain_pager.h`) | DERIVED | +| metres | `v * *(float*)(CTerrain+0x34) + *(float*)(CTerrain+0x3c)` (exactly what `MakeMap` does, `35d3e0`) | | DERIVED | +| `sub_terrain_util::GetHeightmap(int srcLevels, const vector&, int dstLevels, x0, y0, x1, y1, uint16* out, stride, outX, outY)` `3ac330` | resampling to a coarser level (`3acf50`) or the same level (`30a540`) | pure CPU | DERIVED | +| 257² cache via `33d580` cell +0x08 | 1 m heights | faults the bigmap pager | DERIVED | + +Water in the editor can also be `ecs::component::WaterMesh` entities +(`EngineTerrain::GetWaterMeshes` `2f7ed0`). The preview ignores them and uses the +global level only [DERIVED]. Lakes placed at other levels would need those meshes +(GUESS). + +**Cost estimate** (GUESS, not measured): +- A 1024² minimap of a 512² map needs 2x2 samples per tile: 262,144 + `BaseGetVertices` calls and about 1 M sample reads, roughly 20-80 ms + single-threaded, dominated by cache misses on 8.4 KB tile vectors. +- 2048² (4x4 per tile) is about 4 M reads. +- Only terraformed tiles need re-sampling later. Tile publication `33cd10` (already + patched by `terrain_minmax.h`) is the natural dirty signal. + +**Which `CTerrain`.** The two game states each hold a `CTerrain` (`33d240` copies the +grid, `runtime-memory-audit.md`). The UI reads through the accessor chain in section 4 +(`*(CGameUI+0x450)` → vslot 1 → +0x20). Capturing a raw `CTerrain*` once risks reading +the version the simulation is writing (GUESS). Re-derive it per use. + +**Getting `CGameUI`.** +- `5741d0` (`CGameUI` per-frame update) is already detoured by `tpf2_menu.dll` + (`tpf2-multiplayer/native/src/menu_hook.cpp`, 21-byte steal). A second + `verifyBytes`-guarded hook there would fail when both are loaded. +- Candidate instead: `CreateConstructionMenu` `5a2900`, which runs once per world with + `this` = `CGameUI`. Its prologue `40 55 56 57 41 54 41 55 41 56 41 57 48 8d ac 24 c0 + fa ff ff` is 20 bytes with no RIP-relative operand. [DERIVED] + +Towns, industries, the camera and the street/track network: use Lua (`api.engine`). +`MakeMap(towns/industries only)` is a native alternative but is not cheaper for a few +hundred entities (GUESS). + +## 7. Ranked implementation approaches (512² tiles and beyond) + +### 1. Native RGBA texture behind a Lua-owned `ImageView` (magic `setImage` path). **Recommended.** + +**Native side:** +- Detour `ImageView::SetImage(string)` at `22b4610`. Steal 20 bytes + `40 53 56 57 48 81 ec 90 00 00 00 48 c7 44 24 20 fe ff ff ff`; the next + instruction at `22b4624` is RIP-relative. It has 49 calling functions (70 call + sites), so the non-magic path must be one length check plus a `memcmp`. +- On a magic string, e.g. `bigmap:minimap` (15 chars or fewer, so it stays in the SSO + buffer): + 1. Overwrite the string with a real small UI texture path using the game's + `basic_string::assign` (`83270`), and call the original. That keeps by-value + string ownership stock. + 2. Call `ImageView::SetImage(raw)` `22b4350` with the plugin's RGBA buffer. +- On `bigmap:minimap:update`, push dirty rectangles through `IRenderContext` vslot + `0x218` using `*(ImageView+0x440)->id`. +- Heights come from `CTerrain::BaseGetVertices` `33d130` through the accessor chain. + Colouring reimplements section 2: height ramp from the climate, water depth ramp, + hillshade. +- Roads and rails can be rasterized into the same texture (no line cap) once their + data reaches native code (see risks). + +**Lua side:** +- Window, `api.gui.comp.ImageView.new("")`, then + `setImage("bigmap:minimap", false)` on open and a timed `bigmap:minimap:update`. +- Town and industry markers and the camera rectangle as child components in a + `FloatingLayout` or `AbsoluteLayout`. +- Click → world position → camera move. +- Refresh cadence is Lua's choice. Every native texture write happens synchronously + inside that Lua call, on a live `ImageView`. + +**Risk:** medium-low. Raw-texture `ImageView`s created on the UI thread have in-game +precedent (the map editor). + +**What could break:** +- A game update moves `22b4610`, `22b4350`, `ImageView+0x440`, vslot `0x218` or + `0x4331bb8`. `verifyBytes` catches code, not field offsets. +- Vulkan in-place updates may differ from GL (unmeasured); the fallback is a full + `SetImage` at about 4-16 MB per refresh. +- The texture dies with the `ImageView`, so native code must never cache the + `TextureId`. +- A wrong `CTerrain` (sim-side) read. +- No native road/rail reader exists yet: needs ECS `BaseEdge`/`BaseNode` layouts or a + Lua→file channel. + +### 2. The same texture in a native-owned UI + +- `ImageView` raw ctor `22b2f90` plus native window and layout insertion into + `CGameUI`, with native markers and input. +- Lua does nothing. +- **Risk:** medium-high. The window, layout, input and lifetime APIs were not + researched. +- **What could break:** UI tree ownership (double free when the game tears the UI + down), style sheets, input focus. + +### 3. `MapPreviewComp` with a reduced `Map` + +Sequence 4.B: +- `levels=2`, resolution 64 m, 16 MB of floats at 512². +- `MakeMap(towns, industries)` only, run on the UI thread before `StartGeneration` + `63c4f0`. +- The engine's own colouring and markers are reused. Hosting is still native, because + Lua cannot reference the component. + +**Risk:** medium. + +**What could break:** +- A game-ABI `std::function` must be built in the plugin (layout stable since + VS2015 — GUESS). +- Every refresh rebuilds the `ImageView`, texture and markers and flashes a progress + bar. +- The `Map` plus a 4 MB pixel copy stay resident. +- At `levels=2` the hillshade neighbour step (one sample, 64 m) equals the sampling + stride, so shading is not aliased the way it is at `levels=6` (4 m neighbours + under a 128 m stride). There is still no camera overlay, roads or in-place updates. +- Marker styles may not apply in game (FILE vs GUESS). + +### 4. File-backed texture for a plain Lua `ImageView` + +- Native writes a TGA into a mod `res/textures` folder; Lua calls `setImage(path)`. +- **Risk:** medium. +- **What could break:** + - `TextureManager::AddRef` caches by `TextureDesc` (`2490820`), so a refresh needs a + new file name each time, and old textures accumulate until released. + - `ReloadTexture` needs a `reloadFn` (GL `2583870`) and has no Lua route. + - Disk I/O of 4 MB or more per refresh; stale images. + +### 5. `MapPreviewComp` fed like the map editor (`MakeMap` with `heightmap=true`). **Rejected.** + +4.0 GiB of floats at 512², kept by the component; a 32-bit sample count above 724² +tiles; full UI rebuild per refresh [DERIVED]. + +### 6. A second top-down `CRendererComponent`. **Rejected (GUESS).** + +It renders the 3D scene again, with far-plane and LOD limits over 131 km and a large +frame cost. It is not researched here. + +## Open questions and how to check them read-only + +1. **Vulkan in-place update.** Does vslot `0x218` on an `ImageView`-owned raw texture + display correctly? It needs an in-game test build; code reading cannot settle it. +2. **The industry-spawn `MakeMap` (`92c1d0`).** Log calls and `(X,Y)` on a 256² save. + A working-set spike of about 1 GiB per industry creation would confirm it. +3. **World origin for odd tile counts.** Compare a known town position from + `api.engine` with the formula in section 3. +4. **The accessor class behind `*(CGameUI+0x450)`.** Confirm that its vslot 1 returns + the UI-side (read) game state. + +## Reproduction + +Decompiles are in the session scratchpad `mm\b1`..`mm\b6`, each with its `tN.txt` +target list. They were generated with `tpf2-multiplayer/tools/ghidra/run.ps1 +DecompileTargets.java 240 -ProjDir `. Vtable slots, prologues and float constants were read from +the executable with `pefile` + `capstone` (read-only). String and call-edge lookups +used `C:\tools\ghidra_out\{strings,funcsig,func2src,call_edges,vftables,vtable_dump}.csv`. diff --git a/docs/minimap.md b/docs/minimap.md new file mode 100644 index 0000000..c83113f --- /dev/null +++ b/docs/minimap.md @@ -0,0 +1,165 @@ +# In-game minimap (`minimap=1`) + +Built September 16, 2026. Tested offline (`tools/test_minimap.py`); **not yet run +in game**. Reverse engineering: `minimap-native-preview.md`. + +## Why a new one + +The Workshop "Minimap" mod (3256290611) cannot be patched into working on big +maps. Four things in it are architectural: + +- It finds the map edge with a linear scan that stops at 50,000 m. A 320-tile map + is +-40,960 m and a 512-tile map +-65,536 m. +- It samples heights every 10 m from Lua: about 43 million calls at 256x256 tiles. +- It paints terrain as one UI component per cell. +- It caps roads and rails at about 5,000 lines. + +## Design + +| Part | Where | What it does | +|---|---|---| +| Game script | `mod/minimap/bigmap_minimap.lua`, embedded in the DLL by `tools/embed_lua.ps1`, written to `res/config/game_script/bigmap_minimap.lua` at start | Toolbar button, window, town and industry markers, camera circle, click-to-move, layer toggles, Refresh. GUI only: no `update`, `save` or `load` | +| Request | Lua: `imageView:setImage("bigmap:minimap?x0=&y0=&x1=&y1=&w=&h=&relief=&g=")` | Lua probes the map extent (binary search on `isValidCoordinate`) and owns the world-to-picture mapping, so the image, the markers and the clicks share one definition | +| Detour | `ImageView::SetImage(string)` `0x22b4610` | A 14-byte prefix check for every other image. On a token: the placeholder `ui/icons/main-menu/map_town.tga` is written into the string's own buffer (or an empty path if it does not fit), the original consumes it, then the texture is replaced | +| Terrain | `*(CGameUI+0x450)` -> `vtbl[1]` -> `+0x20` = `CTerrain`; `CGameUI` captured by a 25-byte thunk on `CreateConstructionMenu` `0x5a2900` | The thunk stores `rcx` and jumps on without touching the stack, so the function's unknown arity does not matter | +| Heights | `CTerrain::BaseGetVertices` `0x33d130` (65x65 uint16 per tile) | Not paged by the terrain pager. Empty cells are skipped before the call, which would assert | +| Colour | reimplementation of `CreateTextureData` `0x63b9a0` | Default climate ramp, water depth ramp, hillshade (`relief`), 24-direction ambient term. Shaded on up to 8 threads | +| Upload | `ImageView::SetImage(raw)` `0x22b4350`, RGBA8 | The path the engine's own map preview uses; synchronous | + +Twelve sites are byte-verified before anything is hooked, including the getters +that pin every `CTerrain` offset the renderer reads. Anything that fails leaves +the game untouched and deletes the script, so no button appears without working +hooks. The script is only ever deleted if its first line is the plugin's marker. + +## Offline tests (`tools/test_minimap.py`) + +- The token parser, including 16 rejected forms. +- Every rendered height against an independent model of the engine layout: + origin, packed tile argument, row = y, south in row 0, a missing tile and + pixels outside the map. Every colour is checked to within 1 across water, + land, outside and the multithreaded path. +- The accessor chain, including the vtable range check. +- The detour: non-tokens pass through untouched. Tokens keep their buffer and + capacity, and the uploaded bytes equal a direct render. +- The thunk: executed with four register and two stack arguments. +- The installer against the Steam executable, plus refusal paths. +- Script sync, including never touching a foreign file. +- The Lua script under a mocked game API, plus the multiplayer repo's luacheck. + +## First in-game session (September 16): what it showed + +On a 192x192-tile save at a 175% UI scale (3038x1918 screen) the terrain +rendered correctly and fast (`1024x1024 px ... in 40 ms`), but the window showed +only the lower half of an oversized picture, the markers crowded its top-left +57%, and the camera circle sat off its true position. Measured from the +screenshot, the cause was two unit systems: + +- `setMinimumSize` takes **UI units**, multiplied by the UI scale on screen: a + 900-unit picture was 1,576 px wide. +- `AbsoluteLayout` rect positions, `getContentRect()` and `getMousePos()` are + **screen pixels**: markers placed at 0..900 spread over 0..900 px. The stock + `selectortooltip.lua` mixes these three the same way. +- `AbsoluteLayout` appears to read `rect.y` as the item's **vertical centre**. A + picture at `y = 0` showed exactly its lower half (786 of 1,576 px). The + Workshop minimap placed full-height views at `y = height/2`. INFERRED from that + evidence, not from code. + +The script now measures the UI scale from the map container's content rect, +places everything in screen pixels, and checks the picture's measured position +once per session. If the `rect.y` rule is the other way, it rebuilds with the +other rule. Clicks use `getMousePos()` minus the picture's rect. The toolbar +icon is `ui/button/medium/terrain@2x.tga` (from `res/textures/ui/ui.zip`), not +the Workshop mod's `map@2x.tga`. The log line +`layout: UI scale S (guessed G), AbsoluteLayout y is the ...` records what was +found. + +## Roads, tracks, stations, companies and industry types (September 16) + +All of these are drawn into the picture natively, so a network of any size has +no line cap and no widget per edge. + +- **Gathering (Lua).** When a map build's layout is confirmed, the script reads + every `BASE_EDGE` and then every `STATION` on a 6 ms per-frame budget, showing + "Loading network N%". For each edge it records the class (track, or a street's + `categories[1]`: urban, country or highway; one-way types are told apart by + name), both node positions and both Hermite tangents. For each station it + records the type (rail, road, water or air from the construction file name), + centre, direction and half length. Roadside stops are edge objects, placed via + `getEdgeObject2EdgeMap`. Owners come from `PLAYER_OWNED`. +- **Transport.** The network travels once as text after the token's header: + `P index r g b`, `E class colour x0 y0 tx0 ty0 x1 y1 tx1 ty1`, and + `S class colour x y ux uy half`. The plugin caches it under the header's `net` + id. `layers` (1 roads, 2 tracks, 4 stations) and `hide` (a bit mask of owner + palette indices) re-render from the cache with a header-only request. A request + whose id does not match the cache draws no network, so another world's network + never shows. +- **Drawing (native).** Anti-aliased strokes along each edge's cubic Hermite + curve, with the engine's own tangents. Draw order is roads (urban 70% blend, + country, highway), then tracks, then stations as outlined bars. +- **Companies.** In the multiplayer mod's companies mode, owners map to company + ids through the lobby's `mp_company_cfg.txt`. This machine's player is its + company; the other engine players, in ascending id order, follow the roster + order they were created in. Players that own nothing still count, so the + mapping does not shift. Colours are the lobby chip colours (the + `CM.cmCompanyColor` algorithm, copied). Without a roster, two or more owners + still get distinct colours (this player first). A lone player's network keeps + the classic track colour and shows no company legend. +- **Industry types.** Each industry construction type is listed with the icon of + the cargo it produces and a count, and can be toggled. Markers use the same + icon. The cargo is resolved per type before any marker is placed, in this + order: what any industry of the type has produced + (`game.interface.getEntity(simBuilding).itemsProduced`); the first + `output = { CARGO = n }` table in the type's own construction file, read from + each mod root on `package.path` (`/res/scripts/?.lua`); a built-in table of + the 16 stock industries (their files are zipped in `construction.zip`, so they + cannot be read); what one has taken in; else `map_industry@2x.tga`. +- **Legend.** Show toggles for towns, industries, roads, tracks, stations and the + camera; one row per company, with its colour and a toggle; one row per + industry type. + +A real bug found offline on the way: Lua 5.2's `%d` goes through a 32-bit C +`long` on Windows and refused the network id. Ids and masks are now formatted +with `%.0f`. + +## To confirm in game + +The offline mock cannot settle these. Each is logged or visible: + +1. **The script loads.** The stdout line `[bigmap minimap] installed` should + appear and a map button should show on the main toolbar. This proves + `res/config/game_script` files load without a mod entry. +2. **The picture renders.** Look for `minimap: rendered WxH px ... in N ms` in + `tpf2mp_host.log`. The log line prints the tile rectangle and water level. +3. **Orientation.** North should be up and the picture should not be mirrored. + Check a coast or a lake against the camera. Row 0 is assumed to be the south + edge (`ImageView::DoRender` `0x22b3860`). +4. **Marker alignment.** Town icons should sit on the towns in the picture. + `AbsoluteLayout` rectangles are assumed to be top-left and in UI pixels. The + old Workshop mod placed its line view at `y = height/2`, which hints that + `LineRenderView` may use another origin; if the camera circle is offset + vertically, that is the cause. +5. **Click frame.** The first three map clicks log `event pos` and `screen`. If + `event pos` is not relative to the picture, clicks will move the camera to + the wrong place. +6. **Camera API.** `setCameraData` should move the camera. If it does not, the + script falls back to `game.gui.setCamera` and logs when both fail. +7. **Cost.** Record the render time on a 256x256 and a 512x512 map. + +8. **Network gathering time** on a large map: the `network N: ... in S s` line. + Lua reads about five components per edge. If this is too slow on a 512x512 + network, the next step is a native ECS reader. +9. **Company mapping** in a companies-mode session: every company's tracks + should show in its lobby chip colour, and its legend row should say the + right company number. +10. **Cargo icons:** each industry type should show its cargo. First session + (September 16): some markers were blank. Only industries that had produced + something reported a cargo, markers placed before their type found one + kept the fallback, and that fallback (`map_industry.tga`, likewise + `map_town.tga`) is not a file; `ui.zip` ships only the `@2x` names. Now fixed + as described above. The stdout line `industry icons: N from production, + N from mod files, N from the stock list, N from inputs, N generic (files)` + names any type still generic. +11. **Roadside stops** should appear as small bars on their streets. + +Known limits in this version: temperate terrain colours on every climate; +industry markers capped at 4,000; a network gathered by Lua (see point 8). diff --git a/mod/minimap/bigmap_minimap.lua b/mod/minimap/bigmap_minimap.lua new file mode 100644 index 0000000..228e412 --- /dev/null +++ b/mod/minimap/bigmap_minimap.lua @@ -0,0 +1,1475 @@ +-- tpf2_bigmap minimap: written into res/config/game_script by the Big Maps +-- plugin (tpf2_bigmap.dll) at game start, and removed again when the minimap +-- is switched off or the plugin is uninstalled. Do not edit this copy; the +-- source is tpf2-bigmap/mod/minimap/bigmap_minimap.lua. +-- +-- GUI only: there is no update(), save() or load(), so this cannot change the +-- simulation, a savegame or multiplayer determinism. +-- +-- Drawing. The terrain, roads, tracks and stations are not drawn here. +-- imageView:setImage() with a "bigmap:minimap?..." token is intercepted by the +-- plugin, which renders that world rectangle natively and uploads it as the +-- image's texture. The road and rail network travels once, as text lines after +-- the token's header, and is cached natively under an id; toggling a layer or a +-- company re-sends only the header. Nothing grows a widget or a line primitive +-- per edge, so a 512x512-tile network costs pixels, not UI objects. This script +-- owns the window, the legend, the town and industry markers, the camera +-- marker and click-to-move. +-- +-- Companies. Tracks and stations are coloured by their engine owner +-- (PLAYER_OWNED). In the multiplayer mod's companies mode each company is an +-- engine player; the lobby's mp_company_cfg.txt gives this machine's company +-- and the roster, and colours are the lobby chip colours (mp/companies.lua +-- CM.cmCompanyColor), so a company looks the same here as in the lobby. Any +-- other game with two or more owners gets distinct colours too; a lone +-- player's network keeps the classic track colour. +-- +-- Units. setMinimumSize() takes UI units, which the game multiplies by the UI +-- scale, while AbsoluteLayout positions, getContentRect() and getMousePos() +-- are screen pixels (stock selectortooltip.lua mixes the last three). Things +-- are placed in screen pixels, and sizes divided by a UI scale measured from +-- the map container's content rect. How AbsoluteLayout reads rect.y is not +-- documented (the Workshop minimap treated it as the vertical centre); it is +-- measured once per session and the map rebuilt if the guess was wrong. + +local TEXTURE_LONG_SIDE = 1024 -- texture pixels along the map's long side +local RELIEF = 4 -- hillshade exaggeration (the plugin accepts 0..64) +local VIEW_FRACTION = 0.55 -- picture's long side, as a fraction of the screen's short side +local VIEW_MIN, VIEW_MAX = 300, 1400 -- screen pixels +local MARKER_SIZE = 12 -- UI units +local LEGEND_ICON = 16 -- UI units +local MAX_INDUSTRY_MARKERS = 4000 +local MAX_OWNERS = 52 -- the plugin's palette holds 53 entries +local CAMERA_SEGMENTS = 32 +local INSTALL_FRAMES = 600 -- frames to wait for the main toolbar +local MEASURE_FRAMES = 30 -- frames to wait for the container's first layout +local GATHER_SECONDS = 0.006 -- network gathering budget per frame +local BUTTON_ICON = "ui/button/medium/terrain@2x.tga" +local PLACEHOLDER = "ui/icons/main-menu/map_town.tga" -- the plugin replaces it; must stay 31 chars +-- res/textures/ui/ui.zip ships these two only as @2x: the plain names are no file. +local TOWN_ICON = "ui/icons/main-menu/map_town@2x.tga" +local INDUSTRY_ICON = "ui/icons/main-menu/map_industry@2x.tga" + +-- What each stock industry makes (res/construction/construction.zip, +-- industry/*.con stockListConfig.rule.output). An industry only reports a +-- cargo in itemsProduced once it has produced some, which on a fresh map is +-- almost none of them. +local STOCK_OUTPUT = { + chemical_plant = "PLASTIC", coal_mine = "COAL", construction_material = "CONSTRUCTION_MATERIALS", + farm = "GRAIN", food_processing_plant = "FOOD", forest = "LOGS", fuel_refinery = "FUEL", + goods_factory = "GOODS", iron_ore_mine = "IRON_ORE", machines_factory = "MACHINES", oil_refinery = "OIL", + oil_well = "CRUDE", quarry = "STONE", saw_mill = "PLANKS", steel_mill = "STEEL", tools_factory = "TOOLS", +} +local LAYER_ROADS, LAYER_TRACKS, LAYER_STATIONS = 1, 2, 4 +local EDGE_URBAN, EDGE_COUNTRY, EDGE_HIGHWAY, EDGE_TRACK = 0, 1, 2, 3 +local STATION_RAIL, STATION_ROAD, STATION_WATER, STATION_AIR = 0, 1, 2, 3 + +-- The lobby chip colours (mp/companies.lua CM.CM_COLORS / CM.cmCompanyColor). +local COMPANY_COLORS = { { 220, 80, 80 }, { 80, 140, 230 }, { 90, 190, 110 }, { 230, 180, 60 }, { 180, 100, 220 }, { 80, 200, 200 } } + +local tr = _ or function(s) return s end + +local function clock() + local ok, t = pcall(function() return os.clock() end) + if ok and type(t) == "number" then + return t + end + return nil +end + +local state = { + installed = false, + installTries = 0, + button = nil, + window = nil, + holder = nil, -- BoxLayout the map container lives in + status = nil, -- TextView next to Refresh + companyBox = nil, -- legend: BoxLayout of company rows + companyRows = {}, + industryBox = nil, -- legend: BoxLayout of industry type rows + industryRows = {}, + mapComp = nil, -- Component wrapping the AbsoluteLayout + layout = nil, -- AbsoluteLayout: picture, camera marker, industry and town markers + map = nil, -- { x0, y0, x1, y1 (m), w, h (texture px), vw, vh (screen px), sw, sh } + image = nil, + camera = nil, + step = nil, -- onStep connection + phase = nil, -- "measure" -> "calibrate" -> "live" (or "rebuild") + waitFrames = 0, + scale = 1, -- screen pixels per UI unit + guess = 1, + probe = nil, -- UI-unit width the container was given before measuring + yCentre = true, -- AbsoluteLayout rect.y is the item's vertical centre (else its top) + yKnown = false, + camOffset = nil, -- picture origin in the camera view's coordinates + towns = {}, + industryTypes = {}, -- fileName -> { name, icon, count, shown, markers } + industryTypeOrder = {}, + lastCamera = nil, + generation = 0, + netId = 0, -- id of the network the plugin holds for this world (0 = none yet) + netSerial = 0, + gather = nil, + companies = {}, -- palette rows: { index, pid, label, r, g, b } + hiddenOwners = {}, -- palette index -> true + jobs = {}, + show = { towns = true, industries = true, roads = true, tracks = true, stations = true, camera = true }, + clicksLogged = 0, +} + +local function log(...) + print("[bigmap minimap]", ...) +end + +-- UI changes never run inside an event callback: they are queued and run from +-- guiUpdate. +local function later(job) + state.jobs[#state.jobs + 1] = job +end + +local function round(v) + return math.floor(v + 0.5) +end + +local function setStatus(text) + if state.status then + pcall(function() + state.status:setText(text) + end) + end +end + +-- ---- geometry --------------------------------------------------------------- + +-- The map is centred on the origin. Binary search, so it needs about 22 calls +-- per axis at any size (a linear scan to a fixed limit broke on large maps). +local function probeEdge(axis) + local v = api.type.Vec2f.new(0, 0) + local lo, hi = 0, 4194304 + while hi - lo > 1 do + local mid = math.floor((lo + hi) / 2) + if axis == "x" then + v.x = mid + v.y = 0 + else + v.x = 0 + v.y = mid + end + if api.engine.terrain.isValidCoordinate(v) then + lo = mid + else + hi = mid + end + end + return lo +end + +local function screenSize() + local ok, rect = pcall(game.gui.getContentRect, "mainView") + if ok and type(rect) == "table" and rect[3] and rect[4] and rect[3] > 0 and rect[4] > 0 then + return rect[3], rect[4] + end + return 1920, 1080 +end + +-- Only a starting point: the measured value replaces it before anything is placed. +local function uiScaleGuess(screenH) + local ok, cfg = pcall(api.util.getAppConfig) + if ok and cfg and not cfg.uiAutoScaling and type(cfg.uiScaling) == "number" and cfg.uiScaling > 0 then + return cfg.uiScaling + end + return math.max(1, math.floor(screenH / 1080 * 4 + 0.5) / 4) +end + +local function computeMap() + local ex, ey = probeEdge("x"), probeEdge("y") + if ex < 256 or ey < 256 then + return nil + end + local sw, sh = screenSize() + local long = math.max(VIEW_MIN, math.min(VIEW_MAX, math.floor(math.min(sw, sh) * VIEW_FRACTION))) + local m = { x0 = -ex, y0 = -ey, x1 = ex, y1 = ey, sw = sw, sh = sh } + if ex >= ey then + m.w = TEXTURE_LONG_SIDE + m.h = math.max(16, math.floor(TEXTURE_LONG_SIDE * ey / ex + 0.5)) + m.vw = long + m.vh = math.max(24, math.floor(long * ey / ex + 0.5)) + else + m.h = TEXTURE_LONG_SIDE + m.w = math.max(16, math.floor(TEXTURE_LONG_SIDE * ex / ey + 0.5)) + m.vh = long + m.vw = math.max(24, math.floor(long * ex / ey + 0.5)) + end + return m +end + +-- Picture pixels (screen pixels from the picture's top-left). North (maximum +-- world y) is at the top, matching the texture: the plugin writes the south +-- edge into row 0 and the image draws row 0 at the bottom. +local function toView(x, y) + local m = state.map + return (x - m.x0) / (m.x1 - m.x0) * m.vw, (m.y1 - y) / (m.y1 - m.y0) * m.vh +end + +local function toWorld(px, py) + local m = state.map + return m.x0 + px / m.vw * (m.x1 - m.x0), m.y1 - py / m.vh * (m.y1 - m.y0) +end + +local function contentRect(comp) + if not comp then + return nil + end + local ok, r = pcall(function() + return comp:getContentRect() + end) + if ok and r and type(r.w) == "number" and type(r.h) == "number" and r.w > 0 and r.h > 0 then + return r + end + return nil +end + +-- Screen pixels to UI units; returns the UI-unit size actually set. +local function fixSize(comp, w, h) + local uw = math.max(1, math.floor(w / state.scale + 0.5)) + local uh = math.max(1, math.floor(h / state.scale + 0.5)) + local size = api.gui.util.Size.new(uw, uh) + comp:setMinimumSize(size) + comp:setMaximumSize(size) + return uw, uh +end + +-- Adds an item whose top-left corner is (x, top) in screen pixels. +local function addAt(comp, x, top, w, h) + local r = api.gui.util.Rect.new() + r.x = math.floor(x + 0.5) + if state.yCentre then + r.y = math.floor(top + h / 2 + 0.5) + else + r.y = math.floor(top + 0.5) + end + r.w = math.floor(w + 0.5) + r.h = math.floor(h + 0.5) + state.layout:addItem(comp, r) +end + +-- ---- camera --------------------------------------------------------------- + +local function cameraController() + return api.gui.util.getGameUI():getMainRendererComponent():getCameraController() +end + +local function moveCamera(x, y) + local ok, err = pcall(function() + local cc = cameraController() + local data = cc:getCameraData() + data.x = x + data.y = y + cc:setCameraData(data) + end) + if ok then + return + end + local ok2, err2 = pcall(function() + local cam = game.gui.getCamera() + cam[1] = x + cam[2] = y + game.gui.setCamera(cam) + end) + if not ok2 then + log("could not move the camera: " .. tostring(err) .. " / " .. tostring(err2)) + end +end + +local function focusEntity(entity) + local ok, err = pcall(function() + cameraController():focus(entity, false) + end) + if not ok then + log("could not focus entity " .. tostring(entity) .. ": " .. tostring(err)) + end +end + +local function drawCamera() + if not state.camera or not state.map then + return + end + local ok, d = pcall(function() + return cameraController():getCameraData() + end) + if not ok or not d then + return + end + local last = state.lastCamera + if last and last[1] == d.x and last[2] == d.y and last[3] == d.z then + return + end + state.lastCamera = { d.x, d.y, d.z } + local m = state.map + local cx, cy = toView(d.x, d.y) + if state.camOffset then + cx = cx + state.camOffset[1] + cy = cy + state.camOffset[2] + end + local perMetre = m.vw / (m.x1 - m.x0) + local radius = math.max(4, math.min(m.vw / 4, math.abs(d.z or 0) * perMetre)) + state.camera:clear() + local from, to = api.type.Vec2f.new(0, 0), api.type.Vec2f.new(0, 0) + for i = 0, CAMERA_SEGMENTS - 1 do + local a0 = 2 * math.pi * i / CAMERA_SEGMENTS + local a1 = 2 * math.pi * (i + 1) / CAMERA_SEGMENTS + from.x = cx + radius * math.cos(a0) + from.y = cy + radius * math.sin(a0) + to.x = cx + radius * math.cos(a1) + to.y = cy + radius * math.sin(a1) + state.camera:addLine(from, to) + end +end + +-- ---- companies -------------------------------------------------------------- + +local function companyColor(cid) + cid = tonumber(cid) or 1 + local c = COMPANY_COLORS[cid] + if c then + return c[1] / 255, c[2] / 255, c[3] / 255 + end + local h = ((cid - 7) * 137.508) % 360 + local sat, val = 0.62, 0.85 + local C = val * sat + local X = C * (1 - math.abs((h / 60) % 2 - 1)) + local m = val - C + local r, g, b + if h < 60 then + r, g, b = C, X, 0 + elseif h < 120 then + r, g, b = X, C, 0 + elseif h < 180 then + r, g, b = 0, C, X + elseif h < 240 then + r, g, b = 0, X, C + elseif h < 300 then + r, g, b = X, 0, C + else + r, g, b = C, 0, X + end + return r + m, g + m, b + m +end + +-- The multiplayer mod's lobby config, found the way its lockstep.lua looks +-- for its data folder. Absent or unreadable means not in companies mode. +local function readCompanyConfig() + local cfg = { mode = "coop" } + local function env(name) + local ok, v = pcall(function() return os.getenv(name) end) + if ok and type(v) == "string" and #v > 0 then + return v + end + return nil + end + local function dir(p) + p = p:gsub("\\", "/") + if p:sub(-1) ~= "/" then + p = p .. "/" + end + return p + end + local candidates = {} + local pinned = env("TPF2MP_DATADIR") + if pinned then + candidates[#candidates + 1] = dir(pinned) + end + local localData = env("LOCALAPPDATA") + if localData then + candidates[#candidates + 1] = dir(localData .. "/tpf2mp/data") + end + for i = 1, #candidates do + local okOpen, f = pcall(io.open, candidates[i] .. "mp_company_cfg.txt", "r") + if okOpen and f then + local lines = {} + pcall(function() + for k = 1, 3 do + lines[k] = f:read("*l") + end + end) + pcall(function() f:close() end) + if lines[1] then + cfg.mode = (lines[1]:gsub("%s", "")) + end + cfg.mine = lines[2] and tonumber((lines[2]:gsub("%s", ""))) + if lines[3] then + cfg.roster = {} + for id in lines[3]:gmatch("%d+") do + cfg.roster[#cfg.roster + 1] = tonumber(id) + end + end + return cfg + end + end + return cfg +end + +-- Every engine player, ascending. Remote companies are created with +-- addPlayer() in roster order, so their ids ascend in roster order too; +-- counting every player (not only owners seen) keeps a company that has built +-- nothing yet from shifting the others' colours. +local function allPlayers(owners) + local seen, list = {}, {} + pcall(function() + api.engine.forEachEntityWithComponent(function(e) + if not seen[e] then + seen[e] = true + list[#list + 1] = e + end + end, api.type.ComponentType.PLAYER) + end) + for i = 1, #owners do + if not seen[owners[i]] then + seen[owners[i]] = true + list[#list + 1] = owners[i] + end + end + table.sort(list) + return list +end + +-- Palette rows for the owners seen, in palette-index order, and whether to +-- colour by owner at all. +local function describeOwners(owners) + local cfg = readCompanyConfig() + local mine + pcall(function() mine = api.engine.util.getPlayer() end) + local companyOf = {} + local companiesMode = cfg.mode == "companies" and cfg.mine and cfg.roster + if companiesMode then + if mine then + companyOf[mine] = cfg.mine + end + local others = {} + for i = 1, #cfg.roster do + if cfg.roster[i] ~= cfg.mine then + others[#others + 1] = cfg.roster[i] + end + end + local k = 0 + local players = allPlayers(owners) + for i = 1, #players do + if players[i] ~= mine then + k = k + 1 + companyOf[players[i]] = others[k] + end + end + end + -- Without a roster: rank the local player first, then ascending ids. + local ranked = {} + for i = 1, #owners do + ranked[i] = owners[i] + end + table.sort(ranked, function(a, b) + if a == mine then + return b ~= mine + end + if b == mine then + return false + end + return a < b + end) + local rank = {} + for i = 1, #ranked do + rank[ranked[i]] = i + end + local rows = {} + for i = 1, #owners do + local pid = owners[i] + local cid = companyOf[pid] + local r, g, b = companyColor(cid or rank[pid]) + local label + if cid then + label = string.format(tr("Company %d"), cid) + if pid == mine then + label = label .. " " .. tr("(you)") + end + elseif pid == mine then + label = tr("Your company") + else + label = string.format(tr("Player %s"), tostring(pid)) + end + rows[#rows + 1] = { index = i - 1, pid = pid, label = label, r = round(r * 255), g = round(g * 255), b = round(b * 255) } + end + return rows, (companiesMode and true or false) or #owners >= 2 +end + +-- ---- network gathering -------------------------------------------------------- + +local function newGather() + local ids = {} + api.engine.forEachEntityWithComponent(function(e) + ids[#ids + 1] = e + end, api.type.ComponentType.BASE_EDGE) + return { + phase = "edges", edges = ids, i = 1, stationIds = nil, si = 1, + lines = {}, nodes = {}, streetClass = {}, owners = {}, ownerList = {}, stationCons = {}, + roads = 0, tracks = 0, stations = 0, started = clock(), + } +end + +local function ownerIndex(g, id) + local po + pcall(function() po = api.engine.getComponent(id, api.type.ComponentType.PLAYER_OWNED) end) + local pid = po and po.player + if not pid then + return -1 + end + local idx = g.owners[pid] + if not idx then + if #g.ownerList >= MAX_OWNERS then + return -1 + end + idx = #g.ownerList + g.owners[pid] = idx + g.ownerList[#g.ownerList + 1] = pid + end + return idx +end + +local function nodeXY(g, node) + local p = g.nodes[node] + if not p then + local n = api.engine.getComponent(node, api.type.ComponentType.BASE_NODE) + p = { n.position.x, n.position.y } + g.nodes[node] = p + end + return p[1], p[2] +end + +-- Stock street types carry categories urban / country / highway / one-way; +-- one-way types are told apart by name. +local function streetClass(g, streetType) + local c = g.streetClass[streetType] + if c then + return c + end + c = EDGE_URBAN + pcall(function() + local rep = api.res.streetTypeRep.get(streetType) + local first = rep and rep.categories and rep.categories[1] + local name = api.res.streetTypeRep.getName(streetType) or "" + if first == "highway" then + c = EDGE_HIGHWAY + elseif first == "country" or (first == "one-way" and string.find(name, "country", 1, true)) then + c = EDGE_COUNTRY + end + end) + g.streetClass[streetType] = c + return c +end + +local function gatherEdge(g, id) + local e = api.engine.getComponent(id, api.type.ComponentType.BASE_EDGE) + if not e then + return + end + local cls, colour + if api.engine.getComponent(id, api.type.ComponentType.BASE_EDGE_TRACK) then + cls = EDGE_TRACK + colour = ownerIndex(g, id) + g.tracks = g.tracks + 1 + else + local street = api.engine.getComponent(id, api.type.ComponentType.BASE_EDGE_STREET) + if not street then + return + end + cls = streetClass(g, street.streetType) + colour = -1 + g.roads = g.roads + 1 + end + local x0, y0 = nodeXY(g, e.node0) + local x1, y1 = nodeXY(g, e.node1) + g.lines[#g.lines + 1] = string.format("E %d %d %d %d %d %d %d %d %d %d", cls, colour, + round(x0), round(y0), round(e.tangent0.x), round(e.tangent0.y), + round(x1), round(y1), round(e.tangent1.x), round(e.tangent1.y)) +end + +local function stationClass(fileName) + fileName = fileName or "" + if string.find(fileName, "station/rail", 1, true) then + return STATION_RAIL + elseif string.find(fileName, "station/air", 1, true) or string.find(fileName, "airport", 1, true) then + return STATION_AIR + elseif string.find(fileName, "station/water", 1, true) or string.find(fileName, "harbor", 1, true) + or string.find(fileName, "port", 1, true) then + return STATION_WATER + end + return STATION_ROAD +end + +local function gatherStation(g, id) + local con = api.engine.system.streetConnectorSystem.getConstructionEntityForStation(id) + local cls, x, y, dx, dy, half, colour + if con and con ~= -1 then + if g.stationCons[con] then + return + end + g.stationCons[con] = true + local c = api.engine.getComponent(con, api.type.ComponentType.CONSTRUCTION) + if not c then + return + end + cls = stationClass(c.fileName) + local p = c.transf:cols(3) + local d = cls == STATION_AIR and c.transf:cols(0) or c.transf:cols(1) + x, y, dx, dy = p.x, p.y, d.x, d.y + half = 40 + pcall(function() + local bv = api.engine.getComponent(con, api.type.ComponentType.BOUNDING_VOLUME) + if bv and bv.bbox then + half = math.max(bv.bbox.max.x - bv.bbox.min.x, bv.bbox.max.y - bv.bbox.min.y) / 2 + end + end) + colour = ownerIndex(g, con) + else + -- A stop on a street is an edge object carrying STATION and PLAYER_OWNED. + local edge = g.edgeObjects and g.edgeObjects[id] + local e = edge and api.engine.getComponent(edge, api.type.ComponentType.BASE_EDGE) + if not e then + return + end + local x0, y0 = nodeXY(g, e.node0) + local x1, y1 = nodeXY(g, e.node1) + cls, x, y, dx, dy, half = STATION_ROAD, (x0 + x1) / 2, (y0 + y1) / 2, x1 - x0, y1 - y0, 15 + colour = ownerIndex(g, id) + end + local len = math.sqrt(dx * dx + dy * dy) + if len <= 0 then + dx, dy, len = 1, 0, 1 + end + g.lines[#g.lines + 1] = string.format("S %d %d %d %d %d %d %d", cls, colour, round(x), round(y), + round(dx / len * 1000), round(dy / len * 1000), math.min(5000, round(half))) + g.stations = g.stations + 1 +end + +local renderTerrain +local rebuildCompanyLegend + +local function finishGather(g) + local rows, coloured = describeOwners(g.ownerList) + local head = {} + if coloured then + for i = 1, #rows do + head[#head + 1] = string.format("P %d %d %d %d", rows[i].index, rows[i].r, rows[i].g, rows[i].b) + end + end + state.companies = coloured and rows or {} + state.netSerial = state.netSerial + 1 + -- Unique across world loads (the script's state restarts with each world), + -- and kept inside 32 bits. + local base = 0 + pcall(function() base = math.floor(os.time()) % 1000000 end) + state.netId = base * 1000 + state.netSerial % 1000 + 1 + local payload = table.concat(head, "\n") + if #g.lines > 0 then + payload = payload .. (#head > 0 and "\n" or "") .. table.concat(g.lines, "\n") + end + g.phase = "done" + renderTerrain(payload) + rebuildCompanyLegend() + setStatus("") + local t = clock() + log(string.format("network %.0f: %d road edges, %d track edges, %d stations, %d owners%s%s", + state.netId, g.roads, g.tracks, g.stations, #g.ownerList, coloured and ", coloured by owner" or "", + (t and g.started) and string.format(" in %.2f s", t - g.started) or "")) +end + +local function gatherTick() + local g = state.gather + if not g or g.phase == "done" or not state.image then + return + end + local t0 = clock() + local n = 0 + while true do + if g.phase == "edges" then + if g.i > #g.edges then + g.phase = "stations" + g.stationIds = {} + api.engine.forEachEntityWithComponent(function(e) + g.stationIds[#g.stationIds + 1] = e + end, api.type.ComponentType.STATION) + pcall(function() g.edgeObjects = api.engine.system.streetSystem.getEdgeObject2EdgeMap() end) + else + local id = g.edges[g.i] + g.i = g.i + 1 + pcall(gatherEdge, g, id) + end + elseif g.phase == "stations" then + if g.si > #g.stationIds then + g.edgeObjects = nil + finishGather(g) + return + end + local id = g.stationIds[g.si] + g.si = g.si + 1 + pcall(gatherStation, g, id) + else + return + end + n = n + 1 + if n % 64 == 0 then + local t = clock() + if not t or not t0 then + if n >= 3000 then + break + end + elseif t - t0 >= GATHER_SECONDS then + break + end + end + end + local total = #g.edges + (g.stationIds and #g.stationIds or 0) + local done = (g.i - 1) + (g.si - 1) + setStatus(string.format(tr("Loading network %d%%"), math.floor(100 * done / math.max(1, total)))) +end + +-- ---- towns and industries --------------------------------------------------- + +local function collectTowns() + local ids = {} + api.engine.forEachEntityWithComponent(function(entity) + ids[#ids + 1] = entity + end, api.type.ComponentType.TOWN) + local towns = {} + for i = 1, #ids do + local ok, town = pcall(game.interface.getEntity, ids[i]) + if ok and type(town) == "table" and town.position then + towns[#towns + 1] = { entity = ids[i], x = town.position[1], y = town.position[2], name = town.name } + end + end + return towns +end + +local function firstCargo(items) + if type(items) ~= "table" then + return nil + end + local names = {} + for k in pairs(items) do + if type(k) == "string" and k:sub(1, 1) ~= "_" then + names[#names + 1] = k + end + end + table.sort(names) + return names[1] +end + +local function cargoIcon(cargo) + local icon + pcall(function() + local idx = api.res.cargoTypeRep.find(cargo) + local rep = idx and idx >= 0 and api.res.cargoTypeRep.get(idx) + if rep and type(rep.icon) == "string" and rep.icon ~= "" then + icon = rep.icon + end + end) + return icon +end + +-- "industry/usa/steel_mill.con" -> "steel_mill" +local function industryBaseName(fileName) + return tostring(fileName):match("([^/\\]+)%.con$") or tostring(fileName) +end + +-- Every mod's root, from package.path: the game adds "/res/scripts/?.lua" +-- for each active mod (and the game's own res/scripts, whose constructions are +-- zipped and so never found loose). +local function modRoots() + local roots, seen = {}, {} + pcall(function() + for entry in string.gmatch(tostring(package.path), "[^;]+") do + local root = entry:gsub("\\", "/"):match("^(.-)/?res/scripts/%?%.lua$") + if root and not seen[root] then + seen[root] = true + roots[#roots + 1] = root + end + end + end) + return roots +end + +-- A mod industry's output, read from its own construction file: the first +-- cargo id in the first "output = { ... }" table, which is how the stock +-- industryutil stockListConfig and the mods built on it spell the rule. +local function conFileOutput(fileName) + local roots = modRoots() + for i = #roots, 1, -1 do -- later mods override earlier ones + local path = (roots[i] ~= "" and (roots[i] .. "/") or "") .. "res/construction/" .. fileName + local ok, text = pcall(function() + local f = io.open(path, "r") + if not f then + return nil + end + local s = f:read("*a") + f:close() + return s + end) + if ok and type(text) == "string" then + for body in text:gmatch("output%s*=%s*(%b{})") do + local cargo = body:match("(%u[%u%d_]*)%s*=") + if cargo then + return cargo, path + end + end + return nil, path + end + end + return nil +end + +-- One entry per industry construction type; its icon is set by resolveIndustryIcons. +local function industryType(fileName) + local t = state.industryTypes[fileName] + if not t then + local base = industryBaseName(fileName) + local pretty = base:gsub("_", " "):gsub("^%l", string.upper) + t = { file = fileName, name = pretty, icon = INDUSTRY_ICON, source = nil, count = 0, shown = true, markers = {} } + pcall(function() + local rep = api.res.constructionRep.get(api.res.constructionRep.find(fileName)) + if rep and rep.description and rep.description.name and rep.description.name ~= "" then + t.name = tr(rep.description.name) + end + end) + state.industryTypes[fileName] = t + state.industryTypeOrder[#state.industryTypeOrder + 1] = t + end + return t +end + +-- Every type's icon before any marker is placed, so all markers of a type share +-- it. The cargo comes from, in order: what any industry of the type has produced +-- (authoritative), the output rule in a mod's own construction file (a mod +-- industry, or a mod's replacement of a stock one), the stock table (the stock +-- files are zipped), what one has taken in, and else the generic icon. Only a +-- produced cargo is final; production is asked again on the next build. +local function resolveIndustryIcons(industries) + local sims = {} + for i = 1, #industries do + local t = industryType(industries[i].file) + if t.source ~= "produced" and industries[i].sim then + sims[t] = sims[t] or {} + table.insert(sims[t], industries[i].sim) + end + end + local counts = { produced = 0, file = 0, stock = 0, consumed = 0, generic = 0 } + local generic = {} + for i = 1, #state.industryTypeOrder do + local t = state.industryTypeOrder[i] + local list = sims[t] + if list then + local produced, consumed + for k = 1, #list do + local ok, ent = pcall(game.interface.getEntity, list[k]) + if ok and type(ent) == "table" then + produced = firstCargo(ent.itemsProduced) + consumed = consumed or firstCargo(ent.itemsConsumed) + if produced then + break + end + end + end + if t.fileOutput == nil then + t.fileOutput = conFileOutput(t.file) or false -- read a construction file once per session + end + local cargo, source = produced, "produced" + if not cargo and t.fileOutput then + cargo, source = t.fileOutput, "file" + end + if not cargo and STOCK_OUTPUT[industryBaseName(t.file)] then + cargo, source = STOCK_OUTPUT[industryBaseName(t.file)], "stock" + end + if not cargo and consumed then + cargo, source = consumed, "consumed" + end + local icon = cargo and cargoIcon(cargo) + if not icon then + source = "generic" + end + t.icon, t.source, t.cargo = icon or INDUSTRY_ICON, source, icon and cargo or nil + end + if t.source then + counts[t.source] = counts[t.source] + 1 + if t.source == "generic" then + generic[#generic + 1] = t.file + end + end + end + log(string.format("industry icons: %d from production, %d from mod files, %d from the stock list, %d from inputs, " + .. "%d generic%s", counts.produced, counts.file, counts.stock, counts.consumed, counts.generic, + #generic > 0 and (" (" .. table.concat(generic, ", ") .. ")") or "")) +end + +local function collectIndustries() + local ids = {} + api.engine.forEachEntityWithComponent(function(entity) + ids[#ids + 1] = entity + end, api.type.ComponentType.SIM_BUILDING) + local seen, list = {}, {} + for i = 1, #ids do + if #list >= MAX_INDUSTRY_MARKERS then + log("industry markers capped at " .. MAX_INDUSTRY_MARKERS) + break + end + local ok, con = pcall(api.engine.system.streetConnectorSystem.getConstructionEntityForSimBuilding, ids[i]) + if ok and con and con ~= -1 and not seen[con] then + seen[con] = true + local ok2, info = pcall(function() + local c = api.engine.getComponent(con, api.type.ComponentType.CONSTRUCTION) + if not c or not c.fileName or not string.find(c.fileName, "industry/", 1, true) then + return nil + end + local p = c.transf:cols(3) + local nameComp = api.engine.getComponent(con, api.type.ComponentType.NAME) + return { entity = con, sim = ids[i], x = p.x, y = p.y, file = c.fileName, + name = nameComp and nameComp.name or c.fileName } + end) + if ok2 and info then + list[#list + 1] = info + end + end + end + return list +end + +local function addMarker(icon, item) + local px, py = toView(item.x, item.y) + local img = api.gui.comp.ImageView.new(icon) + local size = api.gui.util.Size.new(MARKER_SIZE, MARKER_SIZE) + img:setMinimumSize(size) + img:setMaximumSize(size) + if item.name and item.name ~= "" then + img:setTooltip(item.name) + end + local entity = item.entity + img:insertMouseListener(function(e) + if e.button == 0 and e.type == 2 then + later(function() + focusEntity(entity) + end) + return true + end + return false + end) + local s = MARKER_SIZE * state.scale + addAt(img, px - s / 2, py - s / 2, s, s) + return img +end + +local function applyVisibility() + for i = 1, #state.towns do + state.towns[i]:setVisible(state.show.towns, false) + end + for i = 1, #state.industryTypeOrder do + local t = state.industryTypeOrder[i] + for k = 1, #t.markers do + t.markers[k]:setVisible(state.show.industries and t.shown, false) + end + end + if state.camera then + state.camera:setVisible(state.show.camera, false) + end +end + +-- ---- legend ------------------------------------------------------------------- + +local function clearRows(box, rows) + for i = #rows, 1, -1 do + local row = rows[i] + pcall(function() + box:removeItem(row) + row:destroy() + end) + rows[i] = nil + end +end + +local function legendRow(parts) + local layout = api.gui.layout.BoxLayout.new("HORIZONTAL") + for i = 1, #parts do + layout:addItem(parts[i]) + end + local row = api.gui.comp.Component.new("") + row:setLayout(layout) + return row +end + +-- A colour chip: one thick line in a small line view. +local function swatch(r, g, b) + local view = api.gui.comp.LineRenderView.new() + local w, h = 18, 10 + local size = api.gui.util.Size.new(w, h) + view:setMinimumSize(size) + view:setMaximumSize(size) + view:setColor(api.type.Vec4f.new(r / 255, g / 255, b / 255, 1)) + local sh = h * state.scale + view:setWidth(sh) + view:addLine(api.type.Vec2f.new(0, sh / 2), api.type.Vec2f.new(w * state.scale, sh / 2)) + return view +end + +rebuildCompanyLegend = function() + if not state.companyBox then + return + end + clearRows(state.companyBox, state.companyRows) + for i = 1, #state.companies do + local c = state.companies[i] + local box = api.gui.comp.CheckBox.new("") + box:setSelected(not state.hiddenOwners[c.index], false) + local index = c.index + box:onToggle(function(on) + state.hiddenOwners[index] = not on or nil + later(function() + renderTerrain() + end) + end) + local row = legendRow({ box, swatch(c.r, c.g, c.b), api.gui.comp.TextView.new(c.label) }) + state.companyBox:addItem(row) + state.companyRows[#state.companyRows + 1] = row + end +end + +local function rebuildIndustryLegend() + if not state.industryBox then + return + end + clearRows(state.industryBox, state.industryRows) + table.sort(state.industryTypeOrder, function(a, b) return a.name < b.name end) + for i = 1, #state.industryTypeOrder do + local t = state.industryTypeOrder[i] + local box = api.gui.comp.CheckBox.new("") + box:setSelected(t.shown, false) + box:onToggle(function(on) + t.shown = on + later(applyVisibility) + end) + local icon = api.gui.comp.ImageView.new(t.icon) + local size = api.gui.util.Size.new(LEGEND_ICON, LEGEND_ICON) + icon:setMinimumSize(size) + icon:setMaximumSize(size) + local row = legendRow({ box, icon, api.gui.comp.TextView.new(string.format("%s (%d)", t.name, t.count)) }) + state.industryBox:addItem(row) + state.industryRows[#state.industryRows + 1] = row + end +end + +-- ---- map ------------------------------------------------------------------ + +-- The picture: terrain always; roads, tracks and stations from the plugin's +-- cached network, if it is this world's. `payload` (network text) replaces +-- that cache. +renderTerrain = function(payload) + local m = state.map + if not m or not state.image then + return + end + state.generation = state.generation + 1 + local layers = 0 + if state.show.roads then + layers = layers + LAYER_ROADS + end + if state.show.tracks then + layers = layers + LAYER_TRACKS + end + if state.show.stations then + layers = layers + LAYER_STATIONS + end + local hide = 0 + for index, hidden in pairs(state.hiddenOwners) do + if hidden then + hide = hide + 2 ^ index + end + end + -- %.0f, not %d, for the id and the mask: Lua 5.2's %d goes through a C long, + -- which is 32-bit on Windows, and rejects anything larger. + local token = string.format("bigmap:minimap?x0=%d&y0=%d&x1=%d&y1=%d&w=%d&h=%d&relief=%d&g=%d&layers=%d&net=%.0f&hide=%.0f", + m.x0, m.y0, m.x1, m.y1, m.w, m.h, RELIEF, state.generation, layers, state.netId, hide) + if payload then + token = token .. "\n" .. payload + end + state.image:setImage(token, false) +end + +-- A click on the picture moves the camera there. The position comes from +-- getMousePos() and the picture's content rect, which share screen pixels; +-- the first clicks are logged with the event's own position for comparison. +local function onMapMouse(e) + if not (e.button == 0 and e.type == 2) or not state.map or state.phase ~= "live" then + return false + end + local m = state.map + local px, py, source + local ir = contentRect(state.image) + local okMouse, mouse = pcall(game.gui.getMousePos) + if ir and okMouse and type(mouse) == "table" and mouse[1] and mouse[2] then + px = (mouse[1] - ir.x) * m.vw / ir.w + py = (mouse[2] - ir.y) * m.vh / ir.h + source = "mouse" + else + local p = e.pos or e.position + if p then + px, py, source = p.x, p.y, "event" + end + end + if state.clicksLogged < 3 then + state.clicksLogged = state.clicksLogged + 1 + local p = e.pos or e.position + log(string.format("map click: picture (%s, %s) from %s; event pos (%s, %s); picture rect %s", + tostring(px), tostring(py), tostring(source), tostring(p and p.x), tostring(p and p.y), + ir and string.format("%d,%d %dx%d", ir.x, ir.y, ir.w, ir.h) or "?")) + end + if not (px and py) or px < 0 or py < 0 or px > m.vw or py > m.vh then + return false + end + local x, y = toWorld(px, py) + later(function() + moveCamera(x, y) + end) + return true +end + +local function clearMap() + if state.step then + pcall(function() + state.step:disconnect() + end) + state.step = nil + end + if state.mapComp then + local comp = state.mapComp + state.mapComp = nil + pcall(function() + state.holder:removeItem(comp) + comp:destroy() + end) + end + state.layout, state.image, state.camera, state.lastCamera, state.camOffset = nil, nil, nil, nil, nil + state.towns = {} + for i = 1, #state.industryTypeOrder do + state.industryTypeOrder[i].markers = {} + state.industryTypeOrder[i].count = 0 + end + state.phase = nil + state.gather = nil +end + +-- Everything that depends on the measured UI scale. +local function populate() + local m = state.map + state.image = api.gui.comp.ImageView.new(PLACEHOLDER) + fixSize(state.image, m.vw, m.vh) + state.image:insertMouseListener(onMapMouse) + addAt(state.image, 0, 0, m.vw, m.vh) + renderTerrain() -- terrain now; this world's previous network, if any, until the new one is in + + -- Above the picture, below the markers, and clickable like the picture. + state.camera = api.gui.comp.LineRenderView.new() + state.camera:setColor(api.type.Vec4f.new(1, 1, 1, 1)) + state.camera:setWidth(2) + state.camera:insertMouseListener(onMapMouse) + addAt(state.camera, 0, 0, m.vw, m.vh) + + local industries = collectIndustries() + resolveIndustryIcons(industries) + for i = 1, #industries do + local t = industryType(industries[i].file) + t.count = t.count + 1 + t.markers[#t.markers + 1] = addMarker(t.icon, industries[i]) + end + local towns = collectTowns() + for i = 1, #towns do + state.towns[#state.towns + 1] = addMarker(TOWN_ICON, towns[i]) + end + -- Types with no industry left on this map drop out of the legend. + local kept = {} + for i = 1, #state.industryTypeOrder do + if state.industryTypeOrder[i].count > 0 then + kept[#kept + 1] = state.industryTypeOrder[i] + else + state.industryTypes[state.industryTypeOrder[i].file] = nil + end + end + state.industryTypeOrder = kept + rebuildIndustryLegend() + applyVisibility() + log(string.format("map %dx%d m in a %dx%d px picture, texture %dx%d, %d towns, %d industries of %d types", + m.x1 - m.x0, m.y1 - m.y0, m.vw, m.vh, m.w, m.h, #towns, #industries, #state.industryTypeOrder)) +end + +-- Only once the layout is confirmed: a calibration rebuild would discard it. +local function startGather() + state.gather = newGather() + setStatus(tr("Loading network")) +end + +local buildMap + +local function logLayout() + local m = state.map + log(string.format("layout: UI scale %.3f (guessed %.2f), AbsoluteLayout y is the %s, picture %dx%d px", + state.scale, state.guess, state.yCentre and "vertical centre" or "top edge", m.vw, m.vh)) +end + +local function onMapStep() + local m = state.map + if not m or not state.mapComp then + return + end + if state.phase == "measure" then + local cr = contentRect(state.mapComp) + if cr then + state.scale = cr.w / state.probe + else + state.waitFrames = state.waitFrames + 1 + if state.waitFrames < MEASURE_FRAMES then + return + end + state.scale = state.guess + log("layout: the map container never reported a size; using the UI scale guess") + end + if not (state.scale > 0.2 and state.scale < 10) then + state.scale = state.guess + end + fixSize(state.mapComp, m.vw, m.vh) + populate() + if state.yKnown then + state.phase = "live" + logLayout() + startGather() + else + state.phase = "calibrate" + end + return + end + if state.phase == "calibrate" then + local cr, ir = contentRect(state.mapComp), contentRect(state.image) + if not (cr and ir) then + return + end + local dx, dy = ir.x - cr.x, ir.y - cr.y + local tol = math.max(3, m.vh * 0.02) + state.yKnown = true + if math.abs(dy) > tol and math.abs(math.abs(dy) - m.vh / 2) <= tol then + state.yCentre = not state.yCentre + state.phase = "rebuild" + log(string.format("layout: picture landed %d px off vertically, so AbsoluteLayout y is the %s; rebuilding", + dy, state.yCentre and "vertical centre" or "top edge")) + later(buildMap) + return + end + if math.abs(dx) > tol or math.abs(dy) > tol then + log(string.format("layout: unexpected picture offset (%d, %d) px", dx, dy)) + end + logLayout() + state.phase = "live" + startGather() + end + if state.phase == "live" then + if not state.camOffset then + local ir, vr = contentRect(state.image), contentRect(state.camera) + if ir and vr then + state.camOffset = { ir.x - vr.x, ir.y - vr.y } + state.lastCamera = nil + end + end + drawCamera() + end +end + +buildMap = function() + clearMap() + local m = computeMap() + if not m then + log("could not work out the map extent") + return + end + state.map = m + state.guess = uiScaleGuess(m.sh) + state.scale = state.guess + state.layout = api.gui.layout.AbsoluteLayout.new() + local comp = api.gui.comp.Component.new("") + comp:setLayout(state.layout) + state.probe = fixSize(comp, m.vw, m.vh) + state.holder:addItem(comp) + state.mapComp = comp + state.phase = "measure" + state.waitFrames = 0 + state.step = comp:onStep(function() + local ok, err = pcall(onMapStep) + if not ok then + log("map step: " .. tostring(err)) + end + end) +end + +-- ---- window and toolbar ----------------------------------------------------- + +local function checkbox(label, selected, onToggle) + local box = api.gui.comp.CheckBox.new(tr(label)) + box:setSelected(selected, false) + box:onToggle(onToggle) + return box +end + +local function buildWindow() + local columns = api.gui.layout.BoxLayout.new("HORIZONTAL") + + local mapColumn = api.gui.layout.BoxLayout.new("VERTICAL") + local bar = api.gui.layout.BoxLayout.new("HORIZONTAL") + local refresh = api.gui.comp.Button.new(api.gui.comp.TextView.new(tr("Refresh")), true) + refresh:onClick(function() + later(buildMap) + end) + bar:addItem(refresh) + state.status = api.gui.comp.TextView.new("") + bar:addItem(state.status) + local barComp = api.gui.comp.Component.new("") + barComp:setLayout(bar) + mapColumn:addItem(barComp) + state.holder = api.gui.layout.BoxLayout.new("VERTICAL") + local holderComp = api.gui.comp.Component.new("") + holderComp:setLayout(state.holder) + mapColumn:addItem(holderComp) + local mapColumnComp = api.gui.comp.Component.new("") + mapColumnComp:setLayout(mapColumn) + columns:addItem(mapColumnComp) + + local legend = api.gui.layout.BoxLayout.new("VERTICAL") + legend:addItem(api.gui.comp.TextView.new(tr("Show"))) + local function markerToggle(label, key) + legend:addItem(checkbox(label, true, function(on) + state.show[key] = on + later(applyVisibility) + end)) + end + local function pictureToggle(label, key) + legend:addItem(checkbox(label, true, function(on) + state.show[key] = on + later(function() + renderTerrain() + end) + end)) + end + markerToggle("Towns", "towns") + markerToggle("Industries", "industries") + pictureToggle("Roads", "roads") + pictureToggle("Tracks", "tracks") + pictureToggle("Stations", "stations") + markerToggle("Camera", "camera") + legend:addItem(api.gui.comp.TextView.new(tr("Companies"))) + state.companyBox = api.gui.layout.BoxLayout.new("VERTICAL") + local companyComp = api.gui.comp.Component.new("") + companyComp:setLayout(state.companyBox) + legend:addItem(companyComp) + legend:addItem(api.gui.comp.TextView.new(tr("Industry types"))) + state.industryBox = api.gui.layout.BoxLayout.new("VERTICAL") + local industryComp = api.gui.comp.Component.new("") + industryComp:setLayout(state.industryBox) + legend:addItem(industryComp) + local legendComp = api.gui.comp.Component.new("") + legendComp:setLayout(legend) + columns:addItem(legendComp) + + local window = api.gui.comp.Window.new(tr("Minimap"), columns) + window:addHideOnCloseHandler() + window:onClose(function() + if state.button then + state.button:setSelected(false, false) + end + end) + window:setVisible(false, false) + return window +end + +local function openWindow() + local sw, sh = screenSize() + state.window:setPosition(math.floor(sw * 0.15), math.floor(sh * 0.08)) + state.window:setVisible(true, false) + if not state.mapComp then + buildMap() + end +end + +local function install() + local main = api.gui.util.getById("mainButtonsLayout") + if not main then + return false + end + local layout = main:getItem(0) + if not layout then + return false + end + local icon = api.gui.comp.ImageView.new(BUTTON_ICON) + icon:setMinimumSize(api.gui.util.Size.new(48, 48)) + icon:setMaximumSize(api.gui.util.Size.new(60, 60)) + local button = api.gui.comp.ToggleButton.new(icon) + button:setTooltip(tr("Minimap (Big Maps)")) + button:setMinimumSize(api.gui.util.Size.new(48, 48)) + layout:insertItem(button, 0) + state.button = button + state.window = buildWindow() + button:onToggle(function(on) + if on then + later(openWindow) + else + later(function() + state.window:close() + end) + end + end) + log("installed") + return true +end + +-- Retried each frame until the toolbar exists; an error stops the retries so a +-- half-built button is never added twice. +local function tryInstall() + if state.installed or state.installTries >= INSTALL_FRAMES then + return + end + state.installTries = state.installTries + 1 + local ok, result = pcall(install) + if ok then + state.installed = result + else + state.installTries = INSTALL_FRAMES + log("could not add the minimap button: " .. tostring(result)) + end +end + +local function runJobs() + local jobs = state.jobs + if #jobs == 0 then + return + end + state.jobs = {} + for i = 1, #jobs do + local ok, err = pcall(jobs[i]) + if not ok then + log("error: " .. tostring(err)) + end + end +end + +function data() + return { + guiInit = tryInstall, + guiUpdate = function() + tryInstall() + runJobs() + local ok, err = pcall(gatherTick) + if not ok then + log("network: " .. tostring(err)) + state.gather = nil + setStatus("") + end + end, + } +end diff --git a/src/bigmap.cpp b/src/bigmap.cpp index 25ebd86..e7d34c3 100644 --- a/src/bigmap.cpp +++ b/src/bigmap.cpp @@ -868,6 +868,10 @@ static bool WriteWholeFile(const wchar_t* path, const char* data, size_t len) return ok; } +// The in-game minimap: native terrain texture behind a Lua ImageView. Uses +// ReadWholeFile / WriteWholeFile above for its game script. +#include "minimap.h" + // The stock text with the inserts, malloc'd; nullptr (and `why`) when an anchor // is missing, repeated or out of order. static char* PatchBaseModText(const char* stock, size_t len, size_t* outLen, char* why, size_t whyLen) @@ -1033,6 +1037,9 @@ void WINAPI BigmapRestoreStockBaseMod(HWND, HINSTANCE, LPSTR, int) if (_snwprintf_s(path, MAX_PATH, _TRUNCATE, L"%s\\res\\config\\base_mod.lua", self) < 0) return; char why[320]; SyncBaseMod(path, false, why, sizeof why); + // The minimap game script goes too (only if it is ours). + wchar_t script[MAX_PATH]; + if (MinimapScriptPathIn(self, script, MAX_PATH)) SyncMinimapScript(script, false); } // --------------------------------------------------------------------------- @@ -1234,6 +1241,7 @@ int Tpf2mpPluginInit(const Tpf2mpHost* host, Tpf2mpPluginInfo* out) (g_materialCompress==1 && (g_materialWarmMB<0 || g_materialWarmMB>g_materialHotMB)); g_saveFast = H->cfgBool("tpf2_bigmap", "save_fast", 0) != 0; g_instanceShrink = H->cfgBool("tpf2_bigmap", "instance_shrink", 0) != 0; + g_minimap = H->cfgBool("tpf2_bigmap", "minimap", 0) != 0; if (g_octreeDepth != 11 && g_octreeDepth != 12 && g_octreeDepth != 13) { H->log("octree_depth must be 11, 12 or 13; refusing invalid depth"); return TPF2MP_ERR_FAILED; @@ -1320,6 +1328,7 @@ int Tpf2mpPluginInit(const Tpf2mpHost* host, Tpf2mpPluginInfo* out) "binary -- every RVA here was measured on those two, so " "refusing to patch (re-run the recon if the game updated)"); SyncGameBaseMod(false); // no density levels on an unknown build: stock file back + SyncGameMinimapScript(false); // nor a minimap button whose hooks cannot install return TPF2MP_ERR_BUILD; } } @@ -1343,6 +1352,7 @@ int Tpf2mpPluginInit(const Tpf2mpHost* host, Tpf2mpPluginInfo* out) installed += InstallMaterialCompression(); installed += InstallSaveFast(); installed += InstallInstanceShrink(); + installed += InstallMinimap(); // ---- street occupancy raster: scale cell size with map size ----------- if (g_rasterOn) { diff --git a/src/minimap.h b/src/minimap.h new file mode 100644 index 0000000..3486f51 --- /dev/null +++ b/src/minimap.h @@ -0,0 +1,935 @@ +// Steam 35924: an in-game minimap that works on the largest maps. +// +// The picture is not drawn by Lua. The minimap game script +// (mod/minimap/bigmap_minimap.lua, written into res/config/game_script at +// start) calls an ordinary ImageView's setImage() with a token +// +// bigmap:minimap?x0=&y0=&x1=&y1=&w=&h=[&relief=][&g=] +// +// and the detour on ImageView::SetImage(string) (0x22b4610, the only image +// method Lua can reach) renders that world rectangle from the live terrain and +// uploads it as the image's texture through ImageView::SetImage(raw) +// (0x22b4350), the path the engine's own map preview uses. There is no widget +// per pixel and no line primitive, so the cost is the pixel count, not the map +// size. Every other image path passes straight through after a prefix check. +// +// Heights come from CTerrain::BaseGetVertices (0x33d130), the 65x65 base +// heightmap per tile, which the height-cache pager never touches. The terrain +// is reached the way the UI reaches it: *(CGameUI+0x450) -> vtbl[1] -> +0x20. +// CGameUI is captured by a thunk on CreateConstructionMenu (0x5a2900), which +// runs once per world. Colouring follows the engine's CreateTextureData +// (0x63b9a0): the default climate height ramp, a water depth ramp, a hillshade +// and a 24-direction ambient term. +// +// Reverse engineering: docs/minimap-native-preview.md. Included after H, +// g_gog, ReadWholeFile and WriteWholeFile. Steam only. +#pragma once +#include +#include +#include +#include +#include "../out/minimap_lua.inc" // kMinimapLuaParts[], generated by build.bat + +static bool g_minimap = false; + +// ---- game ABI ------------------------------------------------------------- +// MSVC x64 release std::string: 16-byte SSO buffer or heap pointer, then size +// and capacity. Data lives in the buffer while capacity <= 15. +struct MinimapMsvcString { + union { char buf[16]; char* ptr; }; + uint64_t size, cap; +}; +static char* MinimapStringData(MinimapMsvcString* s) { return s->cap > 15 ? s->ptr : s->buf; } + +using MinimapSetImagePathFn = void* (__fastcall*)(void* imageView, MinimapMsvcString* path, uint64_t flag); +using MinimapSetImageRawFn = void* (__fastcall*)(void* imageView, int channels, int w, int h, + const void* pixels /* const vector& */, uint64_t validate); +// const vector& BaseGetVertices(CVec2i tile): the tile arrives packed in +// rdx, x in the low 32 bits and y in the high 32 (0x33d13a..0x33d150). +using MinimapVerticesFn = const void* (__fastcall*)(void* terrain, uint64_t tile); + +static MinimapSetImagePathFn g_minimapSetImagePath = nullptr; // trampoline +static MinimapSetImageRawFn g_minimapSetImageRaw = nullptr; +static MinimapVerticesFn g_minimapBaseGetVertices = nullptr; +static void* g_minimapGameUI = nullptr; // written by the capture thunk +static void* g_minimapCreateMenuTrampoline = nullptr; +static uintptr_t g_minimapImageLo = 0, g_minimapImageHi = 0; + +// ---- request token -------------------------------------------------------- +// One header line, optionally followed by "\n" and a network payload: +// +// bigmap:minimap?x0=&y0=&x1=&y1=&w=&h=[&relief=][&g=][&layers=][&net=][&hide=] +// P palette entry (owner colour) +// E x0 y0 tx0 ty0 x1 y1 tx1 ty1 edge, Hermite tangents in metres +// S x y ux uy half station: centre, direction x1000, half length +// +// layers: 1 roads, 2 tracks, 4 stations. net: the id of the network to draw; a +// payload replaces the cached network under that id, and a header-only request +// draws the cache only if its id matches, so a previous world's network never +// shows. hide: bit mask of palette indices (companies) not to draw. +static const char kMinimapToken[] = "bigmap:minimap"; +enum { MINIMAP_TOKEN_LEN = 14, MINIMAP_MIN_PX = 16, MINIMAP_MAX_PX = 4096, MINIMAP_HEADER_MAX = 1024 }; +enum { MINIMAP_LAYER_ROADS = 1, MINIMAP_LAYER_TRACKS = 2, MINIMAP_LAYER_STATIONS = 4 }; +enum { MINIMAP_EDGE_URBAN = 0, MINIMAP_EDGE_COUNTRY = 1, MINIMAP_EDGE_HIGHWAY = 2, MINIMAP_EDGE_TRACK = 3 }; +enum { MINIMAP_MAX_PALETTE = 53 }; // hide is a double-exact integer mask + +struct MinimapRequest { + double x0, y0, x1, y1, relief; + int w, h; + int layers; + uint64_t net, hide; +}; + +static bool MinimapIsToken(const char* text, uint64_t len) { + return len >= MINIMAP_TOKEN_LEN && memcmp(text, kMinimapToken, MINIMAP_TOKEN_LEN) == 0; +} + +// Length of the header line (up to the first newline). +static size_t MinimapHeaderLength(const char* text, size_t len) { + const void* nl = memchr(text, '\n', len); + return nl ? size_t(static_cast(nl) - text) : len; +} + +// Strict: every key known, every value a complete finite number, the rectangle +// non-empty and bounded, the size whole and within [16, 4096]. Parses the +// header line only; a payload after it is MinimapParseNetwork's. +static bool MinimapParse(const char* text, size_t len, MinimapRequest* r) { + char buf[MINIMAP_HEADER_MAX]; + len = MinimapHeaderLength(text, len); + if (!MinimapIsToken(text, len) || len >= sizeof buf) return false; + memcpy(buf, text, len); + buf[len] = 0; + char* p = buf + MINIMAP_TOKEN_LEN; + if (*p++ != '?') return false; + MinimapRequest q{}; + q.relief = 4.0; + unsigned seen = 0; + for (;;) { + char* amp = strchr(p, '&'); + if (amp) *amp = 0; + char* eq = strchr(p, '='); + if (!eq || eq == p) return false; + *eq = 0; + const char* val = eq + 1; + char* end = nullptr; + const double v = strtod(val, &end); + if (end == val || *end != 0 || !std::isfinite(v)) return false; + if (!strcmp(p, "x0")) { q.x0 = v; seen |= 1; } + else if (!strcmp(p, "y0")) { q.y0 = v; seen |= 2; } + else if (!strcmp(p, "x1")) { q.x1 = v; seen |= 4; } + else if (!strcmp(p, "y1")) { q.y1 = v; seen |= 8; } + else if (!strcmp(p, "w")) { if (v != std::floor(v)) return false; q.w = int(v < 0 ? -1 : v > 1e6 ? 1e6 : v); seen |= 16; } + else if (!strcmp(p, "h")) { if (v != std::floor(v)) return false; q.h = int(v < 0 ? -1 : v > 1e6 ? 1e6 : v); seen |= 32; } + else if (!strcmp(p, "relief")) { if (v < 0 || v > 64) return false; q.relief = v; } + else if (!strcmp(p, "g")) {} // generation counter: makes each request a new string + else if (!strcmp(p, "layers")) { if (v < 0 || v > 7 || v != std::floor(v)) return false; q.layers = int(v); } + else if (!strcmp(p, "net")) { if (v < 0 || v > 9007199254740992.0 || v != std::floor(v)) return false; q.net = uint64_t(v); } + else if (!strcmp(p, "hide")) { if (v < 0 || v > 9007199254740991.0 || v != std::floor(v)) return false; q.hide = uint64_t(v); } + else return false; + if (!amp) break; + p = amp + 1; + } + if (seen != 63) return false; + const double lim = 1e7; + if (!(q.x1 > q.x0 && q.y1 > q.y0 && std::fabs(q.x0) <= lim && std::fabs(q.x1) <= lim && + std::fabs(q.y0) <= lim && std::fabs(q.y1) <= lim)) return false; + if (q.w < MINIMAP_MIN_PX || q.h < MINIMAP_MIN_PX || q.w > MINIMAP_MAX_PX || q.h > MINIMAP_MAX_PX) return false; + *r = q; + return true; +} + +// ---- network payload ---------------------------------------------------------- +struct MinimapNetEdge { float x0, y0, tx0, ty0, x1, y1, tx1, ty1; uint8_t cls; int8_t colour; }; +struct MinimapNetStation { float x, y, ux, uy, half; uint8_t cls; int8_t colour; }; +struct MinimapNet { + uint64_t id = 0; + std::vector edges; + std::vector stations; + uint32_t palette[MINIMAP_MAX_PALETTE] = {}; + bool paletteSet[MINIMAP_MAX_PALETTE] = {}; + size_t rejected = 0; +}; +static MinimapNet g_minimapNet; +static SRWLOCK g_minimapNetLock = SRWLOCK_INIT; + +// Reads whitespace-separated numbers from one line; false on anything else. +static bool MinimapReadNumbers(const char*& p, const char* end, double* out, int count) { + for (int i = 0; i < count; ++i) { + while (p < end && (*p == ' ' || *p == '\t')) ++p; + if (p >= end) return false; + char tmp[48]; + size_t n = 0; + while (p < end && *p != ' ' && *p != '\t' && *p != '\r' && n < sizeof tmp - 1) tmp[n++] = *p++; + if (n == 0 || n == sizeof tmp - 1) return false; + tmp[n] = 0; + char* stop = nullptr; + out[i] = strtod(tmp, &stop); + if (*stop != 0 || !std::isfinite(out[i])) return false; + } + while (p < end && (*p == ' ' || *p == '\t' || *p == '\r')) ++p; + return p == end; +} + +// Parses the lines after the header into `net`. Malformed lines are counted +// and skipped; limits keep a hostile string from exhausting memory. +static void MinimapParseNetwork(const char* p, const char* end, MinimapNet* net) { + const double lim = 1e7; + while (p < end) { + const char* nl = static_cast(memchr(p, '\n', size_t(end - p))); + const char* lineEnd = nl ? nl : end; + const char kind = *p; + const char* q = p + 1; + double v[10]; + bool ok = false; + if (kind == 'P' && MinimapReadNumbers(q, lineEnd, v, 4)) { + const int idx = int(v[0]); + if (v[0] == idx && idx >= 0 && idx < MINIMAP_MAX_PALETTE && v[1] >= 0 && v[1] <= 255 && + v[2] >= 0 && v[2] <= 255 && v[3] >= 0 && v[3] <= 255) { + net->palette[idx] = (uint32_t(v[1]) << 16) | (uint32_t(v[2]) << 8) | uint32_t(v[3]); + net->paletteSet[idx] = true; + ok = true; + } + } else if (kind == 'E' && MinimapReadNumbers(q, lineEnd, v, 10)) { + bool inRange = v[0] >= 0 && v[0] <= 3 && v[0] == std::floor(v[0]) && + v[1] >= -1 && v[1] < MINIMAP_MAX_PALETTE && v[1] == std::floor(v[1]); + for (int i = 2; i < 10; ++i) inRange = inRange && std::fabs(v[i]) <= lim; + if (inRange && net->edges.size() < 8000000) { + net->edges.push_back({ float(v[2]), float(v[3]), float(v[4]), float(v[5]), float(v[6]), float(v[7]), + float(v[8]), float(v[9]), uint8_t(v[0]), int8_t(v[1]) }); + ok = true; + } + } else if (kind == 'S' && MinimapReadNumbers(q, lineEnd, v, 7)) { + bool inRange = v[0] >= 0 && v[0] <= 3 && v[0] == std::floor(v[0]) && + v[1] >= -1 && v[1] < MINIMAP_MAX_PALETTE && v[1] == std::floor(v[1]) && + v[6] >= 0 && v[6] <= 5000; + for (int i = 2; i < 6; ++i) inRange = inRange && std::fabs(v[i]) <= lim; + if (inRange && net->stations.size() < 1000000) { + const double len = std::sqrt(v[4] * v[4] + v[5] * v[5]); + net->stations.push_back({ float(v[2]), float(v[3]), float(len > 0 ? v[4] / len : 1.0), + float(len > 0 ? v[5] / len : 0.0), float(v[6]), uint8_t(v[0]), int8_t(v[1]) }); + ok = true; + } + } else if (lineEnd == p || (lineEnd == p + 1 && *p == '\r')) { + ok = true; // blank line + } + if (!ok) ++net->rejected; + p = nl ? nl + 1 : end; + } +} + +// ---- terrain -------------------------------------------------------------- +struct MinimapTerrain { + void* self; + const uint8_t* cells; // 0x28-byte cells, tile entity id at +0 (-1 = none) + int x0, y0, w, h, perTile; // tile rectangle and base samples per tile edge + float resX, resY, zScale, zOffset, water; +}; + +// Offsets pinned by the byte-verified getters: levels +0x28 (0x33d270), +// resolution +0x2c/+0x30/+0x34 (0x33d280), height offset +0x3c (0x33d560), +// water level +0x40 (0x33d7f0), tile grid header at *(+0x18) (0x33d540). +static bool MinimapReadTerrain(void* t, MinimapTerrain* m) { + if (!t) return false; + const uint8_t* self = static_cast(t); + const uint8_t* hdr = nullptr; + memcpy(&hdr, self + 0x18, sizeof hdr); + if (!hdr) return false; + int levels = 0; + memcpy(&levels, self + 0x28, 4); + memcpy(&m->x0, hdr + 0x00, 4); + memcpy(&m->y0, hdr + 0x04, 4); + memcpy(&m->w, hdr + 0x08, 4); + memcpy(&m->h, hdr + 0x0c, 4); + memcpy(&m->cells, hdr + 0x10, sizeof m->cells); + memcpy(&m->resX, self + 0x2c, 4); + memcpy(&m->resY, self + 0x30, 4); + memcpy(&m->zScale, self + 0x34, 4); + memcpy(&m->zOffset, self + 0x3c, 4); + memcpy(&m->water, self + 0x40, 4); + if (levels < 1 || levels > 10 || m->w < 1 || m->h < 1 || m->w > 16384 || m->h > 16384 || !m->cells) return false; + if (!(m->resX > 0 && m->resY > 0 && std::isfinite(m->resX) && std::isfinite(m->resY) && + std::isfinite(m->zScale) && std::isfinite(m->zOffset) && std::isfinite(m->water))) return false; + m->perTile = 1 << levels; + m->self = t; + return true; +} + +// Heights in metres at pixel centres; NaN outside the tile rectangle or on a +// missing tile. Row 0 is the SOUTH edge (minimum world y): ImageView draws +// texture row 0 at the bottom (0x22b3860), like the engine's preview. +static void MinimapSampleHeights(const MinimapTerrain& m, const MinimapRequest& r, float* out) { + const double tileW = double(m.perTile) * m.resX, tileH = double(m.perTile) * m.resY; + // The engine's own origin for this grid (MapPreviewComp markers, 0x5c9a90): + // (-(W/2) tiles, -(H/2) tiles), integer halves; tile (x0, y0) starts there. + const double originX = -double(m.w / 2) * tileW, originY = -double(m.h / 2) * tileH; + const int side = m.perTile + 1; + const double stepX = (r.x1 - r.x0) / r.w, stepY = (r.y1 - r.y0) / r.h; + uint64_t cachedKey = ~uint64_t(0); + const uint16_t* cached = nullptr; + for (int py = 0; py < r.h; ++py) { + const double fy = (r.y0 + (py + 0.5) * stepY - originY) / tileH; + const double ryf = std::floor(fy); + for (int px = 0; px < r.w; ++px) { + float& o = out[size_t(py) * size_t(r.w) + size_t(px)]; + o = std::numeric_limits::quiet_NaN(); + const double fx = (r.x0 + (px + 0.5) * stepX - originX) / tileW; + const double rxf = std::floor(fx); + if (rxf < 0 || ryf < 0 || rxf >= m.w || ryf >= m.h) continue; + const int rx = int(rxf), ry = int(ryf); + const uint64_t key = (uint64_t(uint32_t(ry)) << 32) | uint32_t(rx); + if (key != cachedKey) { + cachedKey = key; + cached = nullptr; + int entity = -1; + memcpy(&entity, m.cells + (size_t(ry) * size_t(m.w) + size_t(rx)) * 0x28, 4); + // BaseGetVertices asserts on an empty cell (0x33d166), so check first. + if (entity != -1 && g_minimapBaseGetVertices) { + const uint32_t tx = uint32_t(m.x0 + rx), ty = uint32_t(m.y0 + ry); + const void* vec = g_minimapBaseGetVertices(m.self, uint64_t(tx) | (uint64_t(ty) << 32)); + if (vec) { + const uint16_t* b = nullptr; + const uint16_t* e = nullptr; + memcpy(&b, vec, sizeof b); + memcpy(&e, static_cast(vec) + 8, sizeof e); + if (b && e > b && size_t(e - b) >= size_t(side) * size_t(side)) cached = b; + } + } + } + if (!cached) continue; + int ix = int((fx - rxf) * m.perTile + 0.5), iy = int((fy - ryf) * m.perTile + 0.5); + if (ix > m.perTile) ix = m.perTile; + if (iy > m.perTile) iy = m.perTile; + o = float(cached[size_t(iy) * size_t(side) + size_t(ix)]) * m.zScale + m.zOffset; + } + } +} + +// ---- colouring ------------------------------------------------------------ +struct MinimapRgb { float r, g, b; }; +static MinimapRgb MinimapLerp(const MinimapRgb& a, const MinimapRgb& b, float t) { + return { a.r + (b.r - a.r) * t, a.g + (b.g - a.g) * t, a.b + (b.b - a.b) * t }; +} +static MinimapRgb MinimapColour(int r, int g, int b) { return { r / 255.0f, g / 255.0f, b / 255.0f }; } + +// The engine's default PreviewColoring (0x36d810), which temperate uses. +static MinimapRgb MinimapRamp(float h) { + static const float heights[3] = { 0.0f, 100.0f, 550.0f }; + static const MinimapRgb colours[3] = { MinimapColour(93, 112, 66), MinimapColour(64, 78, 50), + MinimapColour(242, 235, 220) }; + if (!(h > heights[0])) return colours[0]; + if (h >= heights[2]) return colours[2]; + const int i = h < heights[1] ? 0 : 1; + return MinimapLerp(colours[i], colours[i + 1], (h - heights[i]) / (heights[i + 1] - heights[i])); +} + +static uint8_t MinimapByte(float v) { + return uint8_t(v <= 0 ? 0 : v >= 1 ? 255 : int(v * 255.0f + 0.5f)); +} + +static void MinimapShadeRows(const MinimapTerrain& m, const MinimapRequest& r, const float* hts, + uint8_t* rgba, int row0, int row1) { + static const MinimapRgb water0 = MinimapColour(100, 135, 158), water1 = MinimapColour(60, 80, 91); + static const MinimapRgb ambientC = MinimapColour(204, 230, 255), sunC = MinimapColour(255, 255, 204); + static const MinimapRgb outside = MinimapColour(26, 28, 32); + // 24 hemisphere directions: 3 rings at theta = (i+0.5)/3 * pi/2 from the + // zenith, 8 azimuths each (as 0x63aa90). + double dirs[24][3]; + double dirZ = 0; + for (int i = 0; i < 3; ++i) + for (int k = 0; k < 8; ++k) { + const double th = (i + 0.5) / 3.0 * 1.5707963267948966, ph = k * 0.7853981633974483; + double* d = dirs[i * 8 + k]; + d[0] = std::sin(th) * std::cos(ph); + d[1] = std::sin(th) * std::sin(ph); + d[2] = std::cos(th); + dirZ += d[2]; + } + const double stepX = (r.x1 - r.x0) / r.w, stepY = (r.y1 - r.y0) / r.h; + const auto at = [&](int x, int y, float fallback) { + if (x < 0 || y < 0 || x >= r.w || y >= r.h) return fallback; + const float v = hts[size_t(y) * size_t(r.w) + size_t(x)]; + return v == v ? v : fallback; + }; + for (int y = row0; y < row1; ++y) { + for (int x = 0; x < r.w; ++x) { + const float h = hts[size_t(y) * size_t(r.w) + size_t(x)]; + MinimapRgb c; + if (!(h == h)) { + c = outside; + } else if (h < m.water) { + float t = (h - m.water) / (-100.0f - m.water); + t = t < 0 ? 0 : t > 1 ? 1 : t; + c = MinimapLerp(water0, water1, t); + } else { + const double gx = (double(at(x + 1, y, h)) - at(x - 1, y, h)) / (2 * stepX) * r.relief; + const double gy = (double(at(x, y + 1, h)) - at(x, y - 1, h)) / (2 * stepY) * r.relief; + const double len = std::sqrt(gx * gx + gy * gy + 1); + const double nx = -gx / len, ny = -gy / len, nz = 1 / len; + const double sun = (std::max)(0.0, (-nx + ny + nz) / std::sqrt(3.0)); + double amb = 3 * nz; + for (int i = 0; i < 24; ++i) + amb += (std::max)(0.0, nx * dirs[i][0] + ny * dirs[i][1] + nz * dirs[i][2]); + amb /= 3 + dirZ; + const MinimapRgb base = MinimapRamp(h); + c = { float(base.r * (amb * ambientC.r + sun * sunC.r) * 0.5), + float(base.g * (amb * ambientC.g + sun * sunC.g) * 0.5), + float(base.b * (amb * ambientC.b + sun * sunC.b) * 0.5) }; + } + uint8_t* p = rgba + (size_t(y) * size_t(r.w) + size_t(x)) * 4; + p[0] = MinimapByte(c.r); + p[1] = MinimapByte(c.g); + p[2] = MinimapByte(c.b); + p[3] = 255; + } + } +} + +// Shading touches only the height buffer, so it can use every core. +static void MinimapShade(const MinimapTerrain& m, const MinimapRequest& r, const float* hts, uint8_t* rgba) { + unsigned n = std::thread::hardware_concurrency(); + if (n < 1) n = 1; + if (n > 8) n = 8; + if (unsigned(r.h) < n * 16) n = 1; + std::thread workers[8]; + unsigned started = 0; + for (unsigned i = 1; i < n; ++i) { + try { + workers[started] = std::thread(MinimapShadeRows, std::cref(m), std::cref(r), hts, rgba, + int(r.h * i / n), int(r.h * (i + 1) / n)); + ++started; + } catch (...) { + MinimapShadeRows(m, r, hts, rgba, int(r.h * i / n), int(r.h * (i + 1) / n)); + } + } + MinimapShadeRows(m, r, hts, rgba, 0, int(r.h / n)); + for (unsigned i = 0; i < started; ++i) workers[i].join(); +} + +// ---- network overlay ------------------------------------------------------ +static int MinimapClampInt(double v, int lo, int hi) { + return v < lo ? lo : v > hi ? hi : int(v); +} + +// Anti-aliased thick segment in texture space (x across, y = texture row, row 0 +// south), blended over what is already there. +static void MinimapStroke(uint8_t* rgba, int w, int h, double ax, double ay, double bx, double by, + double r, uint32_t rgb, double alpha) { + const double ext = r + 1.0; + const int xmin = MinimapClampInt(std::floor((std::min)(ax, bx) - ext), 0, w); + const int xmax = MinimapClampInt(std::ceil((std::max)(ax, bx) + ext), -1, w - 1); + const int ymin = MinimapClampInt(std::floor((std::min)(ay, by) - ext), 0, h); + const int ymax = MinimapClampInt(std::ceil((std::max)(ay, by) + ext), -1, h - 1); + if (xmin > xmax || ymin > ymax) return; + const double dx = bx - ax, dy = by - ay, len2 = dx * dx + dy * dy; + const double cr = (rgb >> 16) & 255, cg = (rgb >> 8) & 255, cb = rgb & 255; + for (int y = ymin; y <= ymax; ++y) { + for (int x = xmin; x <= xmax; ++x) { + const double px = x + 0.5 - ax, py = y + 0.5 - ay; + double t = len2 > 0 ? (px * dx + py * dy) / len2 : 0; + t = t < 0 ? 0 : t > 1 ? 1 : t; + const double ex = px - t * dx, ey = py - t * dy; + double cov = r + 0.5 - std::sqrt(ex * ex + ey * ey); + if (cov <= 0) continue; + if (cov > 1) cov = 1; + const double a = cov * alpha; + uint8_t* p = rgba + (size_t(y) * size_t(w) + size_t(x)) * 4; + p[0] = uint8_t(p[0] + (cr - p[0]) * a + 0.5); + p[1] = uint8_t(p[1] + (cg - p[1]) * a + 0.5); + p[2] = uint8_t(p[2] + (cb - p[2]) * a + 0.5); + } + } +} + +// An edge is a cubic Hermite curve between its nodes with the engine's own +// tangents (metres), so curves follow the track, not the chord. +static void MinimapDrawEdge(uint8_t* rgba, const MinimapRequest& q, const MinimapNetEdge& e, + double r, uint32_t rgb, double alpha) { + const double sx = q.w / (q.x1 - q.x0), sy = q.h / (q.y1 - q.y0); + // The Bezier control points of the same cubic bound it: skip edges off the picture. + const double c1x = e.x0 + e.tx0 / 3.0, c1y = e.y0 + e.ty0 / 3.0, c2x = e.x1 - e.tx1 / 3.0, c2y = e.y1 - e.ty1 / 3.0; + const double minX = (std::min)((std::min)(double(e.x0), double(e.x1)), (std::min)(c1x, c2x)); + const double maxX = (std::max)((std::max)(double(e.x0), double(e.x1)), (std::max)(c1x, c2x)); + const double minY = (std::min)((std::min)(double(e.y0), double(e.y1)), (std::min)(c1y, c2y)); + const double maxY = (std::max)((std::max)(double(e.y0), double(e.y1)), (std::max)(c1y, c2y)); + if ((maxX - q.x0) * sx < -3 || (minX - q.x0) * sx > q.w + 3 || (maxY - q.y0) * sy < -3 || (minY - q.y0) * sy > q.h + 3) + return; + const double chord = std::hypot((e.x1 - e.x0) * sx, (e.y1 - e.y0) * sy); + int steps = chord < 3 ? 1 : int(std::ceil(chord / 3)); + if (steps > 24) steps = 24; + double px = (e.x0 - q.x0) * sx, py = (e.y0 - q.y0) * sy; + for (int i = 1; i <= steps; ++i) { + const double t = double(i) / steps, t2 = t * t, t3 = t2 * t; + const double h00 = 2 * t3 - 3 * t2 + 1, h10 = t3 - 2 * t2 + t, h01 = -2 * t3 + 3 * t2, h11 = t3 - t2; + const double nx = (h00 * e.x0 + h10 * e.tx0 + h01 * e.x1 + h11 * e.tx1 - q.x0) * sx; + const double ny = (h00 * e.y0 + h10 * e.ty0 + h01 * e.y1 + h11 * e.ty1 - q.y0) * sy; + MinimapStroke(rgba, q.w, q.h, px, py, nx, ny, r, rgb, alpha); + px = nx; + py = ny; + } +} + +// Roads under tracks under stations. Roads keep their class colours (towns +// read as street patterns); tracks and stations take their owner's palette +// colour. Nothing is drawn unless the cached network is the one requested. +struct MinimapStyle { double r; uint32_t rgb; double alpha; }; +static const MinimapStyle kMinimapRoadStyles[3] = { + { 0.45, 0xd8d3c8, 0.70 }, // urban + { 0.55, 0xbfb9a9, 0.90 }, // country + { 0.85, 0xf0cd5a, 1.00 }, // highway +}; +static const MinimapStyle kMinimapTrackStyle = { 0.75, 0x3a2e28, 1.0 }; +static const uint32_t kMinimapStationDefaults[4] = { 0xf2f2f2, 0xf2f2f2, 0x9ad0ff, 0xffffff }; + +static size_t MinimapDrawNetwork(uint8_t* rgba, const MinimapRequest& q) { + if (!q.layers || !q.net) return 0; + size_t drawn = 0; + AcquireSRWLockShared(&g_minimapNetLock); + const MinimapNet& net = g_minimapNet; + if (net.id == q.net) { + const auto hidden = [&](int colour) { return colour >= 0 && ((q.hide >> colour) & 1) != 0; }; + const auto paint = [&](int colour, uint32_t fallback) { + return colour >= 0 && net.paletteSet[colour] ? net.palette[colour] : fallback; + }; + if (q.layers & MINIMAP_LAYER_ROADS) + for (int cls = MINIMAP_EDGE_URBAN; cls <= MINIMAP_EDGE_HIGHWAY; ++cls) + for (const auto& e : net.edges) + if (e.cls == cls && !hidden(e.colour)) { + const auto& s = kMinimapRoadStyles[cls]; + MinimapDrawEdge(rgba, q, e, s.r, s.rgb, s.alpha); + ++drawn; + } + if (q.layers & MINIMAP_LAYER_TRACKS) + for (const auto& e : net.edges) + if (e.cls == MINIMAP_EDGE_TRACK && !hidden(e.colour)) { + MinimapDrawEdge(rgba, q, e, kMinimapTrackStyle.r, paint(e.colour, kMinimapTrackStyle.rgb), + kMinimapTrackStyle.alpha); + ++drawn; + } + if (q.layers & MINIMAP_LAYER_STATIONS) { + const double sx = q.w / (q.x1 - q.x0), sy = q.h / (q.y1 - q.y0); + for (const auto& s : net.stations) { + if (hidden(s.colour)) continue; + const double cx = (s.x - q.x0) * sx, cy = (s.y - q.y0) * sy; + double hx = s.ux * s.half * sx, hy = s.uy * s.half * sy; + const double hl = std::hypot(hx, hy); + if (hl > 8) { hx *= 8 / hl; hy *= 8 / hl; } + MinimapStroke(rgba, q.w, q.h, cx - hx, cy - hy, cx + hx, cy + hy, 3.1, 0x1a1a1a, 0.9); + MinimapStroke(rgba, q.w, q.h, cx - hx, cy - hy, cx + hx, cy + hy, 2.2, + paint(s.colour, kMinimapStationDefaults[s.cls]), 1.0); + ++drawn; + } + } + } + ReleaseSRWLockShared(&g_minimapNetLock); + return drawn; +} + +// A token's payload (after its header line) replaces the cached network under +// the request's net id. Returns true if a payload was present and accepted. +static bool MinimapAcceptPayload(const char* data, size_t size, const MinimapRequest& req, MinimapNet* summary) { + const size_t header = MinimapHeaderLength(data, size); + if (header + 1 >= size || req.net == 0) return false; + MinimapNet parsed; + parsed.id = req.net; + MinimapParseNetwork(data + header + 1, data + size, &parsed); + if (summary) { + summary->id = parsed.id; + summary->rejected = parsed.rejected; + } + AcquireSRWLockExclusive(&g_minimapNetLock); + g_minimapNet = std::move(parsed); + if (summary) { + summary->edges.resize(g_minimapNet.edges.size()); + summary->stations.resize(g_minimapNet.stations.size()); + } + ReleaseSRWLockExclusive(&g_minimapNetLock); + return true; +} + +// malloc'd RGBA8 for `r`, or nullptr when the terrain cannot be read. +static uint8_t* MinimapRender(void* terrain, const MinimapRequest& r, MinimapTerrain* info) { + MinimapTerrain m{}; + if (!MinimapReadTerrain(terrain, &m)) return nullptr; + const size_t n = size_t(r.w) * size_t(r.h); + float* hts = static_cast(malloc(n * sizeof(float))); + uint8_t* rgba = static_cast(malloc(n * 4)); + if (!hts || !rgba) { + free(hts); + free(rgba); + return nullptr; + } + MinimapSampleHeights(m, r, hts); + MinimapShade(m, r, hts, rgba); + free(hts); + MinimapDrawNetwork(rgba, r); + if (info) *info = m; + return rgba; +} + +// ---- live wiring ---------------------------------------------------------- +// *(CGameUI+0x450) is the accessor object (loaded at 0x5a2999; every caller of +// 0x8bb7f0 passes the ADDRESS of such a field, which it dereferences once). +// Its vtbl[1] returns the UI game state, whose +0x20 is the CTerrain +// (0x8bb820). Code pointers are range-checked before the virtual call. +static void* MinimapTerrainFromGameUI(void* ui) { + if (!ui) return nullptr; + void* acc = nullptr; + memcpy(&acc, static_cast(ui) + 0x450, sizeof acc); + if (!acc) return nullptr; + uintptr_t vtbl = 0; + memcpy(&vtbl, acc, sizeof vtbl); + if (vtbl < g_minimapImageLo || vtbl + 16 > g_minimapImageHi) return nullptr; + uintptr_t fn = 0; + memcpy(&fn, reinterpret_cast(vtbl + 8), sizeof fn); + if (fn < g_minimapImageLo || fn >= g_minimapImageHi) return nullptr; + void* state = reinterpret_cast(fn)(acc); + if (!state) return nullptr; + void* terrain = nullptr; + memcpy(&terrain, static_cast(state) + 0x20, sizeof terrain); + return terrain; +} + +// A small image the game always has: written over the token inside the +// string's own buffer, so the by-value string keeps stock ownership and the +// original sees an ordinary icon path. The raw texture then replaces it. +static const char kMinimapPlaceholder[] = "ui/icons/main-menu/map_town.tga"; + +static void* __fastcall MinimapSetImagePathDetour(void* imageView, MinimapMsvcString* path, uint64_t flag) { + if (!path || !MinimapIsToken(MinimapStringData(path), path->size)) + return g_minimapSetImagePath(imageView, path, flag); + char* data = MinimapStringData(path); + MinimapRequest req{}; + const bool parsed = MinimapParse(data, size_t(path->size), &req); + // The payload is read before the placeholder overwrites the string's start. + MinimapNet summary; + if (parsed && MinimapAcceptPayload(data, size_t(path->size), req, &summary)) + H->log("minimap: network %llu: %zu edges, %zu stations%s", (unsigned long long)req.net, + summary.edges.size(), summary.stations.size(), summary.rejected ? " (some lines rejected)" : ""); + const size_t placeholder = sizeof kMinimapPlaceholder - 1; + if (path->cap >= placeholder) { + memcpy(data, kMinimapPlaceholder, placeholder + 1); + path->size = placeholder; + } else { + data[0] = 0; + path->size = 0; + } + void* result = g_minimapSetImagePath(imageView, path, flag); // consumes the string + if (!parsed) { + H->log("minimap: malformed request token -- expected bigmap:minimap?x0=&y0=&x1=&y1=&w=&h="); + return result; + } + LARGE_INTEGER freq{}, t0{}, t1{}; + QueryPerformanceFrequency(&freq); + QueryPerformanceCounter(&t0); + MinimapTerrain info{}; + uint8_t* rgba = MinimapRender(MinimapTerrainFromGameUI(g_minimapGameUI), req, &info); + if (!rgba) { + H->log("minimap: no readable terrain for this game UI -- image left blank"); + return result; + } + struct { uint8_t* begin; uint8_t* end; uint8_t* cap; } vec = { + rgba, rgba + size_t(req.w) * size_t(req.h) * 4, rgba + size_t(req.w) * size_t(req.h) * 4 }; + g_minimapSetImageRaw(imageView, 4, req.w, req.h, &vec, 1); // uploads synchronously + free(rgba); + QueryPerformanceCounter(&t1); + H->log("minimap: rendered %dx%d px of (%.0f,%.0f)-(%.0f,%.0f) m in %.1f ms " + "(tiles %dx%d from (%d,%d), %d samples/tile, %.2f m/sample, water %.1f m)", + req.w, req.h, req.x0, req.y0, req.x1, req.y1, + double(t1.QuadPart - t0.QuadPart) * 1000.0 / double(freq.QuadPart), + info.w, info.h, info.x0, info.y0, info.perTile, double(info.resX), double(info.water)); + return result; +} + +// mov rax, &slot; mov [rax], rcx; mov rax, &trampoline; jmp [rax] +// Saves `this` and continues into the original without touching the stack or +// any argument register, so CreateConstructionMenu's arity does not matter. +static uint8_t* MinimapBuildCaptureThunk(void** slot, void** trampoline) { + uint8_t code[25] = { 0x48, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0x48, 0x89, 0x08, + 0x48, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0x20 }; + memcpy(code + 2, &slot, 8); + memcpy(code + 15, &trampoline, 8); + uint8_t* p = static_cast(VirtualAlloc(nullptr, sizeof code, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE)); + if (!p) return nullptr; + memcpy(p, code, sizeof code); + DWORD old = 0; + if (!VirtualProtect(p, sizeof code, PAGE_EXECUTE_READ, &old)) { + VirtualFree(p, 0, MEM_RELEASE); + return nullptr; + } + FlushInstructionCache(GetCurrentProcess(), p, sizeof code); + return p; +} + +// ---- the game script ------------------------------------------------------ +static const char kMinimapScriptMarker[] = "-- tpf2_bigmap minimap"; +enum { + MINIMAP_SCRIPT_ERR = -1, MINIMAP_SCRIPT_ABSENT = 0, MINIMAP_SCRIPT_REMOVED = 1, + MINIMAP_SCRIPT_ALREADY = 2, MINIMAP_SCRIPT_WRITTEN = 3, MINIMAP_SCRIPT_FOREIGN = 4 +}; +static wchar_t g_minimapScriptPathOverride[MAX_PATH] = L""; + +static char* MinimapScriptText(size_t* len) { + size_t total = 0; + for (const char* part : kMinimapLuaParts) total += strlen(part); + char* text = static_cast(malloc(total + 1)); + if (!text) return nullptr; + size_t at = 0; + for (const char* part : kMinimapLuaParts) { + const size_t n = strlen(part); + memcpy(text + at, part, n); + at += n; + } + text[at] = 0; + *len = at; + return text; +} + +// want: make `path` our script. !want: remove it. A file there that we did not +// write (no marker on its first line) is never touched. +static int SyncMinimapScript(const wchar_t* path, bool want) { + const DWORD attrs = GetFileAttributesW(path); + const bool exists = attrs != INVALID_FILE_ATTRIBUTES && !(attrs & FILE_ATTRIBUTE_DIRECTORY); + char* cur = nullptr; + size_t curLen = 0; + if (exists && !ReadWholeFile(path, &cur, &curLen)) return MINIMAP_SCRIPT_ERR; + const size_t markerLen = sizeof kMinimapScriptMarker - 1; + if (exists && (curLen < markerLen || memcmp(cur, kMinimapScriptMarker, markerLen) != 0)) { + free(cur); + return MINIMAP_SCRIPT_FOREIGN; + } + int rc; + if (!want) { + rc = !exists ? MINIMAP_SCRIPT_ABSENT : DeleteFileW(path) ? MINIMAP_SCRIPT_REMOVED : MINIMAP_SCRIPT_ERR; + } else { + size_t len = 0; + char* text = MinimapScriptText(&len); + if (!text) rc = MINIMAP_SCRIPT_ERR; + else if (exists && curLen == len && memcmp(cur, text, len) == 0) rc = MINIMAP_SCRIPT_ALREADY; + else rc = WriteWholeFile(path, text, len) ? MINIMAP_SCRIPT_WRITTEN : MINIMAP_SCRIPT_ERR; + free(text); + } + free(cur); + return rc; +} + +static const char* MinimapScriptResultText(int rc) { + switch (rc) { + case MINIMAP_SCRIPT_ABSENT: return "not present"; + case MINIMAP_SCRIPT_REMOVED: return "removed"; + case MINIMAP_SCRIPT_ALREADY: return "up to date"; + case MINIMAP_SCRIPT_WRITTEN: return "written"; + case MINIMAP_SCRIPT_FOREIGN: return "a file we did not write is in the way -- left alone"; + default: return "could not be read or written"; + } +} + +// \res\config\game_script\bigmap_minimap.lua for a game folder. +static bool MinimapScriptPathIn(const wchar_t* gameDir, wchar_t* out, size_t cch) { + return _snwprintf_s(out, cch, _TRUNCATE, L"%s\\res\\config\\game_script\\bigmap_minimap.lua", gameDir) > 0; +} + +static int SyncGameMinimapScript(bool want) { + wchar_t path[MAX_PATH]; + if (g_minimapScriptPathOverride[0]) { + wcscpy_s(path, g_minimapScriptPathOverride); + } else { + wchar_t exe[MAX_PATH]; + const DWORD n = GetModuleFileNameW(nullptr, exe, MAX_PATH); + wchar_t* slash = (n && n < MAX_PATH) ? wcsrchr(exe, L'\\') : nullptr; + if (!slash) { + H->log("minimap: cannot work out the game folder -- script left alone"); + return MINIMAP_SCRIPT_ERR; + } + *slash = 0; + if (!MinimapScriptPathIn(exe, path, MAX_PATH)) return MINIMAP_SCRIPT_ERR; + } + const int rc = SyncMinimapScript(path, want); + // Quiet when the minimap is off and was never installed. + if (want || rc != MINIMAP_SCRIPT_ABSENT) + H->log("minimap: game script %s", MinimapScriptResultText(rc)); + return rc; +} + +// ---- install -------------------------------------------------------------- +static const uint8_t kMinimapSetImagePathBytes[20] = {0x40,0x53,0x56,0x57,0x48,0x81,0xec,0x90,0x00,0x00,0x00,0x48,0xc7,0x44,0x24,0x20,0xfe,0xff,0xff,0xff}; +static const uint8_t kMinimapCreateMenuBytes[20] = {0x40,0x55,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x8d,0xac,0x24,0xc0,0xfa,0xff,0xff}; +static const uint8_t kMinimapSetImageRawBytes[28] = {0x40,0x55,0x53,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x8d,0xac,0x24,0x48,0xff,0xff,0xff,0x48,0x81,0xec,0xb8,0x01,0x00,0x00}; +// Through the empty-cell check: pins the tile grid header (*(+0x18): x0 +0, +// y0 +4, W +8, cells +0x10), the 0x28-byte cell and the packed tile argument. +static const uint8_t kMinimapBaseGetVerticesBytes[60] = {0x40,0x53,0x48,0x83,0xec,0x20,0x4c,0x8b,0x41,0x18,0x48,0x8b,0xc2,0x48,0xc1,0xe8,0x20,0x48,0x8b,0xd9,0x41,0x2b,0x40,0x04,0x41,0x0f,0xaf,0x40,0x08,0x41,0x2b,0x00,0x03,0xc2,0x48,0x98,0x48,0x8d,0x14,0x80,0x49,0x8b,0x40,0x10,0x8b,0x0c,0xd0,0x89,0x4c,0x24,0x30,0x83,0xf9,0xff,0x0f,0x84,0xa8,0x00,0x00,0x00}; +static const uint8_t kMinimapAccessorBytes[10] = {0x48,0x8b,0x09,0x48,0x8b,0x01,0x48,0xff,0x60,0x08}; +static const uint8_t kMinimapTerrainPtrBytes[18] = {0x48,0x83,0xec,0x28,0xe8,0xc7,0xff,0xff,0xff,0x48,0x8b,0x40,0x20,0x48,0x83,0xc4,0x28,0xc3}; +static const uint8_t kMinimapGameUIAccessorLoadBytes[7] = {0x48,0x8b,0x99,0x50,0x04,0x00,0x00}; +static const uint8_t kMinimapLevelsGetterBytes[4] = {0x8b,0x41,0x28,0xc3}; +static const uint8_t kMinimapResolutionGetterBytes[13] = {0x8b,0x41,0x34,0xf2,0x0f,0x10,0x41,0x2c,0xf2,0x0f,0x11,0x02,0x89}; +static const uint8_t kMinimapOffsetGetterBytes[6] = {0xf3,0x0f,0x10,0x41,0x3c,0xc3}; +static const uint8_t kMinimapWaterGetterBytes[6] = {0xf3,0x0f,0x10,0x41,0x40,0xc3}; +static const uint8_t kMinimapTileRectGetterBytes[8] = {0x4c,0x8b,0x41,0x18,0x41,0x8b,0x40,0x08}; + +static bool MinimapVerifySites() { + static const struct { uintptr_t rva; const uint8_t* bytes; uint32_t len; } sites[] = { + { 0x22b4610, kMinimapSetImagePathBytes, sizeof kMinimapSetImagePathBytes }, + { 0x5a2900, kMinimapCreateMenuBytes, sizeof kMinimapCreateMenuBytes }, + { 0x22b4350, kMinimapSetImageRawBytes, sizeof kMinimapSetImageRawBytes }, + { 0x33d130, kMinimapBaseGetVerticesBytes, sizeof kMinimapBaseGetVerticesBytes }, + { 0x8bb7f0, kMinimapAccessorBytes, sizeof kMinimapAccessorBytes }, + { 0x8bb820, kMinimapTerrainPtrBytes, sizeof kMinimapTerrainPtrBytes }, + { 0x5a2999, kMinimapGameUIAccessorLoadBytes, sizeof kMinimapGameUIAccessorLoadBytes }, + { 0x33d270, kMinimapLevelsGetterBytes, sizeof kMinimapLevelsGetterBytes }, + { 0x33d280, kMinimapResolutionGetterBytes, sizeof kMinimapResolutionGetterBytes }, + { 0x33d560, kMinimapOffsetGetterBytes, sizeof kMinimapOffsetGetterBytes }, + { 0x33d7f0, kMinimapWaterGetterBytes, sizeof kMinimapWaterGetterBytes }, + { 0x33d540, kMinimapTileRectGetterBytes, sizeof kMinimapTileRectGetterBytes }, + }; + for (const auto& s : sites) + if (!H->verifyBytes(s.rva, s.bytes, s.len)) return false; + return true; +} + +// The loaded image's address range, for the vtable sanity check. +static bool MinimapImageRange(uintptr_t base, uintptr_t* lo, uintptr_t* hi) { + const auto dos = reinterpret_cast(base); + if (!base || dos->e_magic != IMAGE_DOS_SIGNATURE) return false; + const auto nt = reinterpret_cast(base + dos->e_lfanew); + if (nt->Signature != IMAGE_NT_SIGNATURE) return false; + *lo = base; + *hi = base + nt->OptionalHeader.SizeOfImage; + return true; +} + +static bool InstallMinimap() { + if (!g_minimap || g_gog) { + if (g_minimap) H->log("minimap: Steam 35924 only; OFF"); + SyncGameMinimapScript(false); + return false; + } + if (!MinimapVerifySites()) { + H->log("minimap: Steam 35924 byte mismatch; OFF"); + SyncGameMinimapScript(false); + return false; + } + const uintptr_t base = H->moduleBase(); + if (!MinimapImageRange(base, &g_minimapImageLo, &g_minimapImageHi)) { + H->log("minimap: cannot read the game image headers; OFF"); + SyncGameMinimapScript(false); + return false; + } + g_minimapSetImageRaw = reinterpret_cast(base + 0x22b4350); + g_minimapBaseGetVertices = reinterpret_cast(base + 0x33d130); + // Capture first: the image hook is useless without a game UI to read from. + uint8_t* thunk = MinimapBuildCaptureThunk(&g_minimapGameUI, &g_minimapCreateMenuTrampoline); + const bool capture = thunk && H->installHook(base + 0x5a2900, reinterpret_cast(thunk), + sizeof kMinimapCreateMenuBytes, &g_minimapCreateMenuTrampoline) != 0; + const bool image = capture && H->installHook(base + 0x22b4610, reinterpret_cast(&MinimapSetImagePathDetour), + sizeof kMinimapSetImagePathBytes, reinterpret_cast(&g_minimapSetImagePath)) != 0; + if (!capture || !image) { + // A lone capture hook only records a pointer; without the script nothing uses it. + H->log("minimap: hook failed (game UI capture %s, image %s); OFF", + capture ? "ok" : "FAILED", image ? "ok" : "FAILED"); + SyncGameMinimapScript(false); + return false; + } + const int rc = SyncGameMinimapScript(true); + const bool ok = rc == MINIMAP_SCRIPT_WRITTEN || rc == MINIMAP_SCRIPT_ALREADY; + H->log("minimap: %s -- the map button is on the main toolbar in game", ok ? "enabled" : "hooks installed but no script"); + return ok; +} + +// ---- offline test entries (tools/test_minimap.py) ------------------------- +extern "C" __declspec(dllexport) +int BigmapTestMinimapParse(const char* token, double* rect, int* size, double* relief) { + MinimapRequest r{}; + if (!MinimapParse(token, strlen(token), &r)) return 0; + rect[0] = r.x0; rect[1] = r.y0; rect[2] = r.x1; rect[3] = r.y1; + size[0] = r.w; size[1] = r.h; + *relief = r.relief; + return 1; +} +extern "C" __declspec(dllexport) +int BigmapTestMinimapParseExtra(const char* token, int* layers, uint64_t* net, uint64_t* hide) { + MinimapRequest r{}; + if (!MinimapParse(token, strlen(token), &r)) return 0; + *layers = r.layers; *net = r.net; *hide = r.hide; + return 1; +} +// Accepts a token's payload exactly as the detour does. out: edges, stations, rejected lines. +extern "C" __declspec(dllexport) +int BigmapTestMinimapAcceptNetwork(const char* token, uint64_t length, uint64_t* out) { + MinimapRequest r{}; + if (!MinimapParse(token, size_t(length), &r)) return 0; + MinimapNet summary; + if (!MinimapAcceptPayload(token, size_t(length), r, &summary)) return 0; + out[0] = summary.edges.size(); out[1] = summary.stations.size(); out[2] = summary.rejected; + return 1; +} +extern "C" __declspec(dllexport) +int BigmapTestMinimapRender(void* terrain, MinimapVerticesFn verts, const char* token, uint8_t* out, uint64_t outBytes) { + MinimapRequest r{}; + if (!MinimapParse(token, strlen(token), &r) || outBytes < uint64_t(r.w) * uint64_t(r.h) * 4) return 0; + const auto old = g_minimapBaseGetVertices; + g_minimapBaseGetVertices = verts; + uint8_t* rgba = MinimapRender(terrain, r, nullptr); + g_minimapBaseGetVertices = old; + if (!rgba) return 0; + memcpy(out, rgba, size_t(r.w) * size_t(r.h) * 4); + free(rgba); + return 1; +} +extern "C" __declspec(dllexport) +int BigmapTestMinimapHeights(void* terrain, MinimapVerticesFn verts, const char* token, float* out, uint64_t count) { + MinimapRequest r{}; + MinimapTerrain m{}; + if (!MinimapParse(token, strlen(token), &r) || count < uint64_t(r.w) * uint64_t(r.h) || !MinimapReadTerrain(terrain, &m)) + return 0; + const auto old = g_minimapBaseGetVertices; + g_minimapBaseGetVertices = verts; + MinimapSampleHeights(m, r, out); + g_minimapBaseGetVertices = old; + return 1; +} +extern "C" __declspec(dllexport) +void* BigmapTestMinimapTerrainFromGameUI(void* ui, uintptr_t lo, uintptr_t hi) { + const uintptr_t oldLo = g_minimapImageLo, oldHi = g_minimapImageHi; + g_minimapImageLo = lo; g_minimapImageHi = hi; + void* t = MinimapTerrainFromGameUI(ui); + g_minimapImageLo = oldLo; g_minimapImageHi = oldHi; + return t; +} +extern "C" __declspec(dllexport) +void* BigmapTestMinimapSetImage(const Tpf2mpHost* host, void* imageView, MinimapMsvcString* path, uint64_t flag, + MinimapSetImagePathFn original, MinimapSetImageRawFn raw, MinimapVerticesFn verts, + void* gameUI, uintptr_t lo, uintptr_t hi) { + const auto oldH = H; + H = host; + g_minimapSetImagePath = original; g_minimapSetImageRaw = raw; g_minimapBaseGetVertices = verts; + g_minimapGameUI = gameUI; g_minimapImageLo = lo; g_minimapImageHi = hi; + void* result = MinimapSetImagePathDetour(imageView, path, flag); + H = oldH; + return result; +} +extern "C" __declspec(dllexport) +void* BigmapTestMinimapCaptureThunk(void** slot, void** trampoline) { + return MinimapBuildCaptureThunk(slot, trampoline); +} +extern "C" __declspec(dllexport) +int BigmapTestSyncMinimapScript(const wchar_t* path, int want) { return SyncMinimapScript(path, want != 0); } +extern "C" __declspec(dllexport) +uint64_t BigmapTestMinimapScriptText(char* out, uint64_t cap) { + size_t len = 0; + char* text = MinimapScriptText(&len); + if (!text) return 0; + if (out && cap > len) memcpy(out, text, len + 1); + free(text); + return len; +} +extern "C" __declspec(dllexport) +int BigmapTestInstallMinimap(const Tpf2mpHost* host, int gog, int enabled, const wchar_t* scriptPath) { + const auto oldH = H; + const bool oldGog = g_gog, oldOn = g_minimap; + H = host; g_gog = gog != 0; g_minimap = enabled != 0; + wcscpy_s(g_minimapScriptPathOverride, scriptPath ? scriptPath : L""); + const bool ok = InstallMinimap(); + g_minimapScriptPathOverride[0] = 0; + H = oldH; g_gog = oldGog; g_minimap = oldOn; + return ok; +} diff --git a/tools/embed_lua.ps1 b/tools/embed_lua.ps1 new file mode 100644 index 0000000..5b73e40 --- /dev/null +++ b/tools/embed_lua.ps1 @@ -0,0 +1,41 @@ +# Turns a Lua file into a C++ include of raw string literal chunks, so the +# plugin carries its game script inside the DLL: one file to deploy, and the +# script can never be out of step with the native code that serves it. +# Called by build.bat: +# powershell -File tools\embed_lua.ps1 -In -Out -Name +# produces `static const char* const Parts[] = { R"BMLUA(...)BMLUA", ... };` +# Chunks stay under 8 KB (MSVC caps one literal at about 16 KB). +param( + [Parameter(Mandatory = $true)][string]$In, + [Parameter(Mandatory = $true)][string]$Out, + [Parameter(Mandatory = $true)][string]$Name +) +$ErrorActionPreference = 'Stop' +$text = [IO.File]::ReadAllText((Resolve-Path $In).Path) -replace "`r`n", "`n" +for ($i = 0; $i -lt $text.Length; $i++) { + $c = [int]$text[$i] + if ($c -gt 126 -or ($c -lt 32 -and $c -ne 10 -and $c -ne 9)) { + throw ("{0}: character U+{1:X4} at offset {2}; the embedded script must be printable ASCII" -f $In, $c, $i) + } +} +$delim = 'BMLUA' +if ($text.Contains(")$delim")) { throw "$In contains the raw-string delimiter )$delim" } +$chunks = New-Object System.Collections.Generic.List[string] +$cur = New-Object System.Text.StringBuilder +foreach ($line in [regex]::Split($text, '(?<=\n)')) { + if ($line.Length -eq 0) { continue } + if ($cur.Length -gt 0 -and $cur.Length + $line.Length -gt 8000) { + $chunks.Add($cur.ToString()) + [void]$cur.Clear() + } + [void]$cur.Append($line) +} +if ($cur.Length -gt 0) { $chunks.Add($cur.ToString()) } +$sb = New-Object System.Text.StringBuilder +[void]$sb.Append("// Generated by tools/embed_lua.ps1 from $In -- do not edit.`n") +[void]$sb.Append("static const char* const $($Name)Parts[] = {`n") +foreach ($chunk in $chunks) { [void]$sb.Append('R"' + $delim + '(' + $chunk + ')' + $delim + '",' + "`n") } +[void]$sb.Append("};`n") +$new = $sb.ToString() +$old = if (Test-Path $Out) { [IO.File]::ReadAllText($Out) } else { '' } +if ($new -ne $old) { [IO.File]::WriteAllText($Out, $new, (New-Object Text.UTF8Encoding($false))) } diff --git a/tools/test_minimap.py b/tools/test_minimap.py new file mode 100644 index 0000000..19902fd --- /dev/null +++ b/tools/test_minimap.py @@ -0,0 +1,1245 @@ +"""In-game minimap: native renderer, SetImage detour, capture thunk, installer, +game-script sync, and the Lua script under a mocked game API. + +No game is launched and nothing under the Steam directory is written. Heights +and colours are checked against an independent model of the engine's layout +(docs/minimap-native-preview.md), on a synthetic CTerrain with a fake +BaseGetVertices. Requires lupa, pefile and capstone. +""" +import ctypes as C +import math +import shutil +import struct +import subprocess +import tempfile +from pathlib import Path + +import capstone +import pefile +from lupa.lua52 import LuaRuntime +from test_world_entry import Host, logtype, basetype, verifytype, hooktype + +ROOT = Path(__file__).resolve().parents[1] +EXE = r'C:\tools\bin\TransportFever2.exe' +SCRIPT = ROOT / 'mod/minimap/bigmap_minimap.lua' +MARKER = b'-- tpf2_bigmap minimap' + + +class MsvcString(C.Structure): + _fields_ = [('buf', C.c_char * 16), ('size', C.c_uint64), ('cap', C.c_uint64)] + + +PathFn = C.CFUNCTYPE(C.c_void_p, C.c_void_p, C.POINTER(MsvcString), C.c_uint64) +RawFn = C.CFUNCTYPE(C.c_void_p, C.c_void_p, C.c_int, C.c_int, C.c_int, C.c_void_p, C.c_uint64) +VertsFn = C.CFUNCTYPE(C.c_void_p, C.c_void_p, C.c_uint64) +StateFn = C.CFUNCTYPE(C.c_void_p, C.c_void_p) + +dll = C.CDLL(str(ROOT / 'out/tpf2_bigmap.dll')) +parse = dll.BigmapTestMinimapParse +parse.argtypes = [C.c_char_p, C.POINTER(C.c_double), C.POINTER(C.c_int), C.POINTER(C.c_double)] +render = dll.BigmapTestMinimapRender +render.argtypes = [C.c_void_p, VertsFn, C.c_char_p, C.c_void_p, C.c_uint64] +heights = dll.BigmapTestMinimapHeights +heights.argtypes = [C.c_void_p, VertsFn, C.c_char_p, C.c_void_p, C.c_uint64] +from_ui = dll.BigmapTestMinimapTerrainFromGameUI +from_ui.argtypes = [C.c_void_p, C.c_uint64, C.c_uint64] +from_ui.restype = C.c_void_p +set_image = dll.BigmapTestMinimapSetImage +set_image.argtypes = [C.POINTER(Host), C.c_void_p, C.POINTER(MsvcString), C.c_uint64, PathFn, RawFn, VertsFn, + C.c_void_p, C.c_uint64, C.c_uint64] +set_image.restype = C.c_void_p +thunk_fn = dll.BigmapTestMinimapCaptureThunk +thunk_fn.argtypes = [C.POINTER(C.c_void_p), C.POINTER(C.c_void_p)] +thunk_fn.restype = C.c_void_p +sync_script = dll.BigmapTestSyncMinimapScript +sync_script.argtypes = [C.c_wchar_p, C.c_int] +script_text = dll.BigmapTestMinimapScriptText +script_text.argtypes = [C.c_char_p, C.c_uint64] +script_text.restype = C.c_uint64 +install = dll.BigmapTestInstallMinimap +install.argtypes = [C.POINTER(Host), C.c_int, C.c_int, C.c_wchar_p] + + +def f32(x): + return struct.unpack('= self.W or ryf >= self.H or (rxf, ryf) in self.missing: + return float('nan') + ix = min(self.per, int((fx - rxf) * self.per + 0.5)) + iy = min(self.per, int((fy - ryf) * self.per + 0.5)) + return self.sample(rxf, ryf, ix, iy) * self.z + self.off + + +def token(rect, w, h, relief=0): + return ('bigmap:minimap?x0=%r&y0=%r&x1=%r&y1=%r&w=%d&h=%d&relief=%r' % (*rect, w, h, relief)).encode() + + +def rgb(r, g, b): + return (f32(r / 255), f32(g / 255), f32(b / 255)) + + +def expected_colour(h, water): + """Flat shading (relief 0): n = (0,0,1), sun = 1/sqrt(3), ambient = 1.""" + if h != h: + c = rgb(26, 28, 32) + elif h < water: + t = max(0.0, min(1.0, (h - water) / (-100 - water))) + a, b = rgb(100, 135, 158), rgb(60, 80, 91) + c = tuple(a[i] + (b[i] - a[i]) * t for i in range(3)) + else: + levels = [(0.0, rgb(93, 112, 66)), (100.0, rgb(64, 78, 50)), (550.0, rgb(242, 235, 220))] + if not h > 0: + base = levels[0][1] + elif h >= 550: + base = levels[2][1] + else: + i = 0 if h < 100 else 1 + t = (h - levels[i][0]) / (levels[i + 1][0] - levels[i][0]) + base = tuple(levels[i][1][k] + (levels[i + 1][1][k] - levels[i][1][k]) * t for k in range(3)) + amb, sun = rgb(204, 230, 255), rgb(255, 255, 204) + s = 1 / math.sqrt(3) + c = tuple(base[k] * (amb[k] + s * sun[k]) * 0.5 for k in range(3)) + return tuple(0 if v <= 0 else 255 if v >= 1 else int(v * 255 + 0.5) for v in c) + + +def check_renderer(): + t = FakeTerrain(W=4, H=2, x0=-2, y0=-1, missing={(3, 1)}) + rect = (-512.0, -256.0, 512.0, 256.0) # exactly the map: tiles are 256 m + w, h = 64, 32 + out = (C.c_float * (w * h))() + assert heights(t.addr, t.verts, token(rect, w, h), out, w * h) + nan = 0 + for py in range(h): + for px in range(w): + want, got = t.height(rect, w, h, px, py), out[py * w + px] + if want != want: + assert got != got, (px, py, got) + nan += 1 + else: + assert got == want, (px, py, got, want) + assert nan == (w // 4) * (h // 2) # exactly the missing tile + # Row 0 is the south edge: the bottom-left pixel reads tile (0, 0) near its + # south-west corner, the top-right one tile (3, 1)'s neighbour (2, 1) region. + assert out[0] == t.sample(0, 0, 2, 2) * t.z + t.off, out[0] + # Every BaseGetVertices call named an existing tile in tile coordinates, never the missing one. + tiles = {(struct.unpack('> 32))[0]) for p in t.calls} + assert tiles == {(t.x0 + rx, t.y0 + ry) for ry in range(2) for rx in range(4)} - {(t.x0 + 3, t.y0 + 1)}, tiles + assert len(t.calls) <= w * h + + # A rectangle wider than the map: the outside is NaN, the inside unchanged. + big = (-1024.0, -512.0, 1024.0, 512.0) + out2 = (C.c_float * (w * h))() + assert heights(t.addr, t.verts, token(big, w, h), out2, w * h) + for py in range(h): + for px in range(w): + want, got = t.height(big, w, h, px, py), out2[py * w + px] + assert (got != got) if want != want else got == want, (px, py) + + # Colours, flat shading, on a render tall enough to use several threads. + t2 = FakeTerrain(W=4, H=2, x0=-2, y0=-1, missing={(0, 1)}, off=-560.0, water=0.0) + w, h = 256, 128 + rgba = (C.c_uint8 * (w * h * 4))() + assert render(t2.addr, t2.verts, token(big, w, h), rgba, len(rgba)) + kinds = {'outside': 0, 'water': 0, 'land': 0} + for py in range(h): + for px in range(w): + hh = t2.height(big, w, h, px, py) + want = expected_colour(hh, t2.water) + got = tuple(rgba[(py * w + px) * 4 + k] for k in range(4)) + assert got[3] == 255 and all(abs(got[k] - want[k]) <= 1 for k in range(3)), (px, py, hh, got, want) + kinds['outside' if hh != hh else 'water' if hh < t2.water else 'land'] += 1 + assert all(v > 200 for v in kinds.values()), kinds # every branch well covered + + # Relief shades slopes but never water or the outside; the output is deterministic. + shaded = (C.c_uint8 * (w * h * 4))() + assert render(t2.addr, t2.verts, token(big, w, h, relief=8), shaded, len(shaded)) + again = (C.c_uint8 * (w * h * 4))() + assert render(t2.addr, t2.verts, token(big, w, h, relief=8), again, len(again)) + assert bytes(shaded) == bytes(again) + changed = 0 + for i in range(w * h): + py, px = divmod(i, w) + hh = t2.height(big, w, h, px, py) + if hh != hh or hh < t2.water: + assert bytes(shaded[i * 4:i * 4 + 4]) == bytes(rgba[i * 4:i * 4 + 4]) + elif bytes(shaded[i * 4:i * 4 + 3]) != bytes(rgba[i * 4:i * 4 + 3]): + changed += 1 + assert changed > 1000, changed + + # Unreadable terrain: refused, not guessed. + bad = FakeTerrain(W=2, H=2, x0=-1, y0=-1) + C.c_int32.from_buffer(bad.terrain, 0x28).value = 0 + assert not render(bad.addr, bad.verts, token(rect, 64, 32), rgba, len(rgba)) + C.c_int32.from_buffer(bad.terrain, 0x28).value = 6 + C.c_uint64.from_buffer(bad.hdr, 0x10).value = 0 + assert not render(bad.addr, bad.verts, token(rect, 64, 32), rgba, len(rgba)) + assert not render(None, bad.verts, token(rect, 64, 32), rgba, len(rgba)) + print('PASS: renderer: every height against the engine layout model (origin, packed tile, row = y, south in row 0, ' + 'missing tile, outside the map), every colour within 1 (water ramp, height ramp, flat shade, threads), ' + 'relief only on land, unreadable terrain refused') + return t + + +# ---- the game UI accessor chain --------------------------------------------- + +class FakeGameUI: + def __init__(self, terrain_addr): + self.ui = (C.c_uint8 * 0x460)() + self.acc = (C.c_uint64 * 1)() + self.vtbl = (C.c_uint64 * 2)() + self.state = (C.c_uint8 * 0x28)() + self.seen = [] + + @StateFn + def state_fn(acc): + self.seen.append(acc) + return C.addressof(self.state) + self.state_fn = state_fn + C.c_uint64.from_buffer(self.ui, 0x450).value = C.addressof(self.acc) + self.acc[0] = C.addressof(self.vtbl) + self.vtbl[1] = C.cast(state_fn, C.c_void_p).value + C.c_uint64.from_buffer(self.state, 0x20).value = terrain_addr or 0 + + @property + def addr(self): + return C.addressof(self.ui) + + +def check_accessor(t): + ui = FakeGameUI(t.addr) + assert from_ui(ui.addr, 0, 2 ** 64 - 1) == t.addr and ui.seen == [C.addressof(ui.acc)] + # A vtable outside the image range is never called. + lo = C.addressof(ui.vtbl) + 64 + assert from_ui(ui.addr, lo, lo + 4096) is None and len(ui.seen) == 1 + assert from_ui(None, 0, 2 ** 64 - 1) is None + C.c_uint64.from_buffer(ui.ui, 0x450).value = 0 + assert from_ui(ui.addr, 0, 2 ** 64 - 1) is None and len(ui.seen) == 1 + print('PASS: terrain accessor *(CGameUI+0x450) -> vtbl[1] -> +0x20, with the vtable range check') + + +# ---- SetImage detour ---------------------------------------------------------- + +def make_string(text): + s = MsvcString() + raw = text.encode() + if len(raw) <= 15: + s.buf = raw + s.cap = 15 + keep = None + else: + keep = C.create_string_buffer(raw, len(raw) + 1) + C.c_uint64.from_buffer(s, 0).value = C.addressof(keep) + s.cap = len(raw) + s.size = len(raw) + return s, keep + + +def string_value(p): + s = p.contents + if s.cap > 15: + return C.string_at(C.c_uint64.from_buffer(s, 0).value, s.size) + return bytes(s.buf)[:s.size] + + +def check_detour(t): + logs = [] + + @logtype + def log(fmt): + logs.append(fmt.decode()) + + @basetype + def no_base(): + return 0 + + @verifytype + def no_verify(rva, p, n): + raise AssertionError('the detour never verifies bytes') + + @hooktype + def no_hook(target, detour, n, out): + raise AssertionError('the detour never installs hooks') + + host = Host(C.sizeof(Host), 1, log, None, None, None, no_base, None, no_verify, no_hook, None, None) + ui = FakeGameUI(t.addr) + originals, raws = [], [] + + @PathFn + def original(iv, path, flag): + s = path.contents + originals.append((iv, string_value(path), s.size, s.cap, C.c_uint64.from_buffer(s, 0).value, flag)) + return 0xabc + + @RawFn + def raw(iv, channels, w, h, vec, validate): + begin, end = C.c_uint64.from_address(vec).value, C.c_uint64.from_address(vec + 8).value + raws.append((iv, channels, w, h, C.string_at(begin, end - begin), validate)) + return 0 + + def call(text, gameui=ui.addr, lo=0, hi=2 ** 64 - 1, iv=0x5150, flag=0x101): + s, keep = make_string(text) + before_ptr = C.c_uint64.from_buffer(s, 0).value + r = set_image(C.byref(host), iv, C.byref(s), flag, original, raw, t.verts, gameui, lo, hi) + return r, s, keep, before_ptr + + # Anything that is not our token reaches the original unchanged, by the same pointer. + for text in ['ui/a.tga', 'ui/icons/windows/overview.tga', 'bigmap:minima', '']: + r, s, keep, ptr = call(text) + assert r == 0xabc and originals[-1][1] == text.encode() and originals[-1][5] == 0x101 and not raws + n_orig = len(originals) + + # A token: the original gets the placeholder written into the SAME buffer + # (ownership unchanged), then the raw upload carries the rendered pixels. + rect = (-512.0, -256.0, 512.0, 256.0) + tok = token(rect, 64, 32).decode() + '&g=3' + r, s, keep, ptr = call(tok) + assert r == 0xabc and len(originals) == n_orig + 1 + iv, value, size, cap, buf_ptr, flag = originals[-1] + assert value == b'ui/icons/main-menu/map_town.tga' and size == 31 and cap == len(tok) and buf_ptr == ptr + assert len(raws) == 1 + iv2, channels, w, h, pixels, validate = raws[0] + assert (iv2, channels, w, h, validate & 0xff) == (0x5150, 4, 64, 32, 1) and len(pixels) == 64 * 32 * 4 + direct = (C.c_uint8 * (64 * 32 * 4))() + assert render(t.addr, t.verts, tok.encode(), direct, len(direct)) + assert pixels == bytes(direct) + assert any(line.startswith('minimap: rendered') for line in logs) + + # A token too short for the placeholder: an empty path (the game's missing + # image), and malformed so nothing is rendered. + r, s, keep, ptr = call('bigmap:minimap') + assert originals[-1][1] == b'' and originals[-1][2] == 0 and len(raws) == 1 + assert any('malformed' in line for line in logs) + # Malformed tokens render nothing. Shorter than the 31-byte placeholder: the + # buffer cannot hold it, so an empty path; long enough: the placeholder. + r, s, keep, ptr = call('bigmap:minimap?x0=1&y0=2') + assert originals[-1][1] == b'' and originals[-1][2] == 0 and len(raws) == 1 + r, s, keep, ptr = call('bigmap:minimap?x0=1&y0=2&x1=3&zoom=4') + assert originals[-1][1] == b'ui/icons/main-menu/map_town.tga' and originals[-1][4] == ptr and len(raws) == 1 + # No game UI, or a vtable outside the image: placeholder, no render, logged. + r, s, keep, ptr = call(tok, gameui=None) + assert len(raws) == 1 and any('no readable terrain' in line for line in logs) + r, s, keep, ptr = call(tok, lo=1, hi=2) + assert len(raws) == 1 + print('PASS: SetImage detour: non-tokens pass through untouched; a token keeps the string buffer and capacity, ' + 'hands the original a real icon path, and uploads exactly the rendered RGBA; short, malformed and ' + 'terrain-less requests render nothing') + + +# ---- capture thunk ---------------------------------------------------------- + +def check_thunk(): + slot, tramp = C.c_void_p(0), C.c_void_p(0) + seen = [] + Six = C.CFUNCTYPE(C.c_int64, C.c_int64, C.c_int64, C.c_int64, C.c_int64, C.c_int64, C.c_int64) + + @Six + def target(a, b, c, d, e, f): + seen.append((a, b, c, d, e, f)) + return 0x77 + + tramp.value = C.cast(target, C.c_void_p).value + thunk = thunk_fn(C.byref(slot), C.byref(tramp)) + assert thunk + code = C.string_at(thunk, 25) + assert code[:2] == b'\x48\xb8' and code[10:13] == b'\x48\x89\x08' and code[13:15] == b'\x48\xb8' and code[23:] == b'\xff\x20' + assert struct.unpack('