From ccf029c5434ee766b64365d35ae8a4925a3ca644 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 19 Aug 2026 23:01:36 +0300 Subject: [PATCH 1/5] a lot of assembly findings --- src/SUMMARY.md | 13 +++ src/auto-traders.md | 20 +++++ src/bugs/patrol-letter-crash.md | 40 ++++++++++ src/ch05-00-merchants.md | 7 +- src/file-formats/rou.md | 31 +++++++ src/letters.md | 44 ++++++++++ src/letters/scripted-letters.md | 43 ++++++++++ .../005b-office-autotrade-setting-change.md | 26 ++++++ .../0066-office-autotrade-lock-change.md | 20 +++++ src/operations/0068-set-trade-route-active.md | 20 +++++ .../0069-route-stop-setting-change.md | 27 +++++++ .../006a-trade-route-stop-town-change.md | 27 +++++++ src/ships.md | 2 +- src/towns.md | 2 +- src/towns/ware-prices/selling-price.md | 2 + src/towns/ware-prices/thresholds.md | 17 ++-- src/ui.md | 80 +++++++++++++++++++ src/ui/auto-trade-goods-dialog.md | 37 +++++++++ src/ui/name-banks.md | 26 ++++++ src/ui/personal-letters-window.md | 40 ++++++++++ src/ui/trade-route-panel.md | 24 ++++++ src/ui/trading-office-window.md | 42 ++++++++++ 22 files changed, 580 insertions(+), 10 deletions(-) create mode 100644 src/bugs/patrol-letter-crash.md create mode 100644 src/letters/scripted-letters.md create mode 100644 src/operations/005b-office-autotrade-setting-change.md create mode 100644 src/operations/0066-office-autotrade-lock-change.md create mode 100644 src/operations/0068-set-trade-route-active.md create mode 100644 src/operations/0069-route-stop-setting-change.md create mode 100644 src/operations/006a-trade-route-stop-town-change.md create mode 100644 src/ui.md create mode 100644 src/ui/auto-trade-goods-dialog.md create mode 100644 src/ui/name-banks.md create mode 100644 src/ui/personal-letters-window.md create mode 100644 src/ui/trade-route-panel.md create mode 100644 src/ui/trading-office-window.md diff --git a/src/SUMMARY.md b/src/SUMMARY.md index 7591b7a..52840b3 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -16,6 +16,11 @@ - [Bath House Bribe Failure](./operations/0043-bath-house-bribe-failure.md) - [Make Town Hall Offer](./operations/0048-make-town-hall-offer.md) - [Tavern Interaction](./operations/0052-tavern-interaction.md) + - [Office Autotrade Setting Change](./operations/005b-office-autotrade-setting-change.md) + - [Office Autotrade Lock Change](./operations/0066-office-autotrade-lock-change.md) + - [Set Trade Route Active](./operations/0068-set-trade-route-active.md) + - [Route Stop Setting Change](./operations/0069-route-stop-setting-change.md) + - [Trade Route Stop Town Change](./operations/006a-trade-route-stop-town-change.md) - [Start Criminal Investigation](./operations/0081-start-criminal-investigation.md) - [Scheduled Tasks](./scheduled-tasks/0000.md) - [Criminal Investigation](./scheduled-tasks/0005-criminal-investigation.md) @@ -59,7 +64,14 @@ - [Impact](./ships/sea-battles/projectiles/impact.md) - [Reefs](./ships/sea-battles/reefs.md) - [Auto Traders](./auto-traders.md) +- [UI](./ui.md) + - [Trading Office Window](./ui/trading-office-window.md) + - [Auto Trade Goods Dialog](./ui/auto-trade-goods-dialog.md) + - [Trade Route Panel](./ui/trade-route-panel.md) + - [Personal Letters Window](./ui/personal-letters-window.md) + - [Name Banks](./ui/name-banks.md) - [Letters](./letters.md) + - [Scripted Letters](./letters/scripted-letters.md) - [Charge](./letters/0a-charge.md) - [Indictment](./letters/19-indictment.md) - [Multiplayer](./multiplayer.md) @@ -75,6 +87,7 @@ - [Bath House Bribes Blunders](./bugs/bath-house-bribes-blunders.md) - [Multiplayer Locks](./bugs/multiplayer-locks.md) - [Uncompressed Trade Route Loading](./bugs/uncompressed-trade-route-loading.md) + - [Patrol Letter Crash](./bugs/patrol-letter-crash.md) - [Patches](./patches.md) - [High Res](./patches/high-res.md) - [Increase Alderman "Found Settlement" Mission Limit](./patches/increase-alderman-found-settlement-limit.md) diff --git a/src/auto-traders.md b/src/auto-traders.md index f79ce7d..5539985 100644 --- a/src/auto-traders.md +++ b/src/auto-traders.md @@ -16,3 +16,23 @@ struct auto_trader unsigned __int8 field_F_merchant_index; }; ``` + +## Buying Discount +Auto traders buy cheaper as their trade skill grows. The captain (`0x004D5347`) and +administrator (`0x004FF7E8`) buying routines both compute the percentage of the +transaction price to pay from the auto trader's `field_A_trade_skill`: + +``` +percent_paid = 2 * (50 - trade_skill / 43) +``` + +`trade_skill / 43` is the displayed 0-5 skill level, so each level is worth 2%, up to +a 10% discount at level 5 (skill byte 215). The administrator routine applies it right +after `get_buy_price` (`0x004FF944`: `price * percent / 100`, with the operand order +flipped above `0x1000000` to avoid overflowing); its sell orders are settled through +`get_sell_price` without any skill adjustment, so the discount is buying-only. An +office whose administrator index (`office+0x2F2`) is invalid pays 100%. + +Office administrators do gain skill like captains do (verified in-game: a long-running +save showed administrator trade levels 1-5), even though the game never displays it - +a level 5 administrator quietly buys everything 10% cheaper. diff --git a/src/bugs/patrol-letter-crash.md b/src/bugs/patrol-letter-crash.md new file mode 100644 index 0000000..75392ee --- /dev/null +++ b/src/bugs/patrol-letter-crash.md @@ -0,0 +1,40 @@ +# Patrol Letter Crash + +## Summary +Opening the personal letters list sometimes crashes the game to desktop while +certain scripted letters are present - most prominently the escort/patrol +mission's "Patrol destination" letters. The crash is long known in the community +as the "patrol mission crash" and looks random: the same letter may crash the +game, show a wrong town in the list, or show no town at all. + +## Details +Every [message](../letters.md) carries a town byte that the letters list draws as +its town column, by indexing the 40-slot town-name +[name bank](../ui/name-banks.md) without a bounds check +(`0x0047D928: mov eax, [edx*4+0x6DDA00]`). The resulting pointer goes straight to +the render DLL's text draw, which dereferences it without any guard +(`ddraw_Dll+0xF100`). + +The [letter script](../letters/scripted-letters.md) creation command stores the +low byte of a script variable as the town byte, unvalidated (`0x004ED4E4`), and +the patrol/escort letter templates pass a variable that is not a town index - +observed bytes include 40, 95, 228 and 255. Drawing such a row reads past the +bank into unrelated globals, and the outcome depends on the value it hits: + +- ids 40..~81 land in the adjacent full town-name table, producing a genuine but + wrong town name (typically the first town, "Edinburgh"); +- a value that points at readable memory usually starts with a zero byte and + draws as an empty town column; +- anything else - colors, coordinates, small integers - crashes the game the + moment the list is drawn. + +Which globals hold what depends on resolution, loaded mods and session history, +which is why the crash appears intermittent. Only the list is affected: the +letter body and header are formatted at creation through the bounded town-name +helper, so reading a letter is always safe. + +## Fix +[mod-fix-patrol-letter-crash](https://github.com/P3Modding/p3-lib/tree/master/mod-fix-patrol-letter-crash) +detours the lookup at `0x0047D928`: town bytes below 40 read the bank as before, +anything else draws an empty string - the same blank town column the unpatched +game shows whenever the wild read happens to survive. diff --git a/src/ch05-00-merchants.md b/src/ch05-00-merchants.md index 9c3a83f..6a35998 100644 --- a/src/ch05-00-merchants.md +++ b/src/ch05-00-merchants.md @@ -197,4 +197,9 @@ 00000648 int field_648; 0000064C int field_64C; 00000650 }; -``` \ No newline at end of file +``` + +`field_19_hometown_index` is the town shown as "Home town" on the Personal screen: the +town holding the merchant's home office. It changes when the player moves the home +office and is distinct from the birth town shown in the same screen's date-of-birth +line. diff --git a/src/file-formats/rou.md b/src/file-formats/rou.md index b3e11cd..19c9771 100644 --- a/src/file-formats/rou.md +++ b/src/file-formats/rou.md @@ -34,3 +34,34 @@ The "direction" of a transaction is encoded in the price and amount: |Negative|Positive|Town -> Ship| The "Max" amount is represented by `1_000_000_000` for both barrel and bundle wares. +Amounts are stored in raw units: display units times the ware scaling (bundles 2000, barrels 200). + +## Action Byte +The action byte combines the stop's repair flag with a first-stop marker: + +|Value|Meaning| +|-|-| +|0x00|repair setting "X"| +|0x01|repair setting "R" (repair at this stop)| +|0x09|repair setting "-"| +|0x04|OR'ed onto the route's logical first stop| + +## Applied Routes at Runtime +Loaded routes live in a global pool of the same 220-byte stop records, prefixed by a +2-byte next-stop index in the record's first two ("Unused") bytes: + +- `[0x006DD72C]` = pool base, `[0x006DD72A]` (u16) = pool record count. +- A route is a circular chain of records through the next-stop indices; the stop + carrying action bit `0x04` is the logical first stop. +- `ship+0x132` (u16) = the pool index of the ship's current route stop; it advances as + the route runs. + +## Loading Path +The game loads a route file through the loader at `0x004D5EE0` (thiscall, +`this = 0x006DD728`): it takes a pointer to an MFC-style string object holding the base +name and forms the path `save\AutoRoute\.rou` itself, returning the decompressed +stop buffer. To attach the route to a ship, `transfer_loaded_traderoute` (`0x005492D0`, +thiscall on the operations struct `0x006DF2F0`) reads the buffer pointer from +`operations+0x930` and the target ship index from `operations+0x934`, validates the +stops, allocates pool records, attaches them to the ship's convoy, and frees the buffer +with the game's own allocator. diff --git a/src/letters.md b/src/letters.md index 293f3a7..15c38b0 100644 --- a/src/letters.md +++ b/src/letters.md @@ -1 +1,45 @@ # Letters +Every message a merchant receives - the personal letters, town announcements, +mission updates - lives in one global message pool, and the letter windows are +views over it. + +## Message Pool +The pool object sits at `0x006DD730`: `+0` holds the pointer to the entry array, +the word at `+0x6` (`0x006DD736`) the current pool size (it grows on demand, +`0x004D6B40`). Entries are 16 bytes: + +|Offset|Meaning| +|-|-| +|`+0x0`|day of the letter's date| +|`+0x1`|bit 7: unread flag; low nibble: month index (displayed month is nibble + 1)| +|`+0x2`|year (u16)| +|`+0x4`|message type byte (valid types are `< 0x86`; free entries hold `0xFF`)| +|`+0x5`|town byte, drawn as the letter list's town column (see the [bug](./bugs/patrol-letter-crash.md))| +|`+0x6`|index of the next message (u16) - the per-merchant chain, or the freelist for free entries| +|`+0x8`|payload; for [scripted letters](./letters/scripted-letters.md) a pointer to a 16-byte descriptor| +|`+0xC`|payload; for scripted letters a pointer to the letter text| + +## Mailboxes +Messages are chained per merchant. The mailbox manager object at `0x006DE4A0` +holds the current date at `+0x0` (day), `+0x1` (month) and `+0x2` (year, u16) and +the merchant count at `+0xA` (`0x006DE4AA`); `0x005303C0` (thiscall(this = +`0x006DE4A0`, merchant)) returns a merchant's mailbox record, whose word at `+0xA` +is the head message index of that merchant's chain. The word at `+0x8` acts as a +gate: broadcast deliveries skip merchants whose gate is nonzero. + +## Adding Messages +`add_message` at `0x004D6530` (thiscall(this = `0x006DD730`, merchant, message*)) +takes a caller-prepared 16-byte message - the caller fills type, town and +payloads - then stamps the current date, sets the unread bit, allocates a pool +slot and links it into the recipient's chain. A merchant argument of `-1` +broadcasts the message to every merchant (skipping gated mailboxes); `-2` and +`-3` take special paths that are not fully mapped (`-3` resolves a recipient +through an office). The function has over 150 call sites - one per message kind - +each preparing its own struct. + +## Display +The letters window groups messages into its four tabs through the byte table at +`0x006C0198`, indexed by message type. Unread messages draw black +(`0xFF000000`), read ones brown (`0xFF5A2406`). See +[Personal Letters Window](./ui/personal-letters-window.md) for the window +internals. diff --git a/src/letters/scripted-letters.md b/src/letters/scripted-letters.md new file mode 100644 index 0000000..7719b0c --- /dev/null +++ b/src/letters/scripted-letters.md @@ -0,0 +1,43 @@ +# Scripted Letters +Mission and event letters (escort/patrol updates, town news, notifications with a +letter body) are produced by a letter script interpreter: a bytecode stream of +commands with byte operands, executed against an array of script variables +(interpreter object `+0xC`). One of its commands creates and sends a complete +letter; its handler starts at `0x004ED4A0`. + +## The Create-Letter Command +The handler allocates a 16-byte [message](../letters.md) and fills it from the +command's operands (`cmd[n]` below) and the script variables (`var[n]`): + +|Field|Value| +|-|-| +|type (`+0x4`)|`0x3C + cmd[1]`; kinds past `0x40` become type `0x71` (`0x004ED4EA`)| +|town (`+0x5`)|the **low byte** of `var[cmd[2]]`, unvalidated (`0x004ED4E4`)| +|descriptor (`+0x8`)|`malloc(0x10)`, filled from `cmd[3..6]` and further variables| +|text (`+0xC`)|`malloc(0x1000)`, the formatted letter body| + +The scripted letter types `0x3C..0x40` are the ones the letters list treats +specially (payload-based icon instead of the type icon table `0x006A52D0`). + +## Text Formatting +The letter body is built by a `%`-substitution engine inside the same +interpreter: it copies the template text, expanding placeholders from replacement +string tables (e.g. `0x006C3040`) and game data. Town names are resolved through +the helper `0x00512B20` against the full town-name table (see +[Name Banks](../ui/name-banks.md)), so the letter text names towns correctly even +when the message's town byte does not hold a town - the two come from different +places. + +## Sending +The handler ends in [add_message](../letters.md) calls: to a single recipient +resolved from a script variable (`0x004EDEAA`, merchant = `var[cmd[5]]`), or in +broadcast loops over every merchant (`0x004EDE30`, and a variant at +`0x004EDDA4`), honoring the mailbox gate word. + +## The Town-Byte Flaw +Because the town byte is the unvalidated low byte of an arbitrary script +variable, letter templates whose variable is not a town index - the +escort/patrol mission's "Patrol destination" letters pass one that reaches values +like 40, 95, 228 or 255 - send letters whose town byte is garbage. The letter +itself is fine; the letters list's town column is not. See +[Patrol Letter Crash](../bugs/patrol-letter-crash.md). diff --git a/src/operations/005b-office-autotrade-setting-change.md b/src/operations/005b-office-autotrade-setting-change.md new file mode 100644 index 0000000..d7bc329 --- /dev/null +++ b/src/operations/005b-office-autotrade-setting-change.md @@ -0,0 +1,26 @@ +# Office Autotrade Setting Change +Operation `0x5B` sets one ware's administrator trade order in a trading office: the +stock amount and the price, where the price's sign encodes the direction. It is +enqueued by the trading office window's administrator view ("Trading Office" side +button). + +|Offset|Type|Value| +|-|-|-| +|0x00|u32|opcode `0x5B`| +|0x04|i32|stock amount, raw units| +|0x08|i32|price: positive = sell (minimum price), negative = buy (maximum price, negated), 0 = no order| +|0x0C|u32|office index| +|0x10|u32|ware index| + +The handler at `0x0053D5C0` (operation switch case `0x536290`) resolves the office by +index and writes the stock to `office+0x354+ware*4` and the price to +`office+0x2F4+ware*4`. It also records the price into the owning merchant's per-ware +price memory and maintains the office's "has administrator orders" flags at +`office+0x2D6`, clearing them when every price is 0. + +If the office's administrator index (`office+0x2F2`) is invalid, the handler instead +clears all 24 prices and stock amounts - an office without an administrator cannot +hold orders. + +There is no direction field: the administrator view's direction arrows are purely a +rendering of the price's sign. diff --git a/src/operations/0066-office-autotrade-lock-change.md b/src/operations/0066-office-autotrade-lock-change.md new file mode 100644 index 0000000..f31cf15 --- /dev/null +++ b/src/operations/0066-office-autotrade-lock-change.md @@ -0,0 +1,20 @@ +# Office Autotrade Lock Change +Operation `0x66` sets or clears one ware's "Lock min. store quantity for auto trade +ships" checkbox in a trading office's administrator view. + +|Offset|Type|Value| +|-|-|-| +|0x00|u32|opcode `0x66`| +|0x04|u32|ware index (validated < 0x18)| +|0x08|u16|merchant index| +|0x0C|u16|town index| +|0x10|u32|lock: 0 clears the bit, anything else sets it| + +The handler (operation switch case `0x53644B`; the equivalent standalone handler is +`0x0053DD90`) resolves the office through the office lookup at `0x005308A0`, whose +argument order is (merchant, town), and toggles the ware's bit in the office's lock +bitmap at `office+0x3B4`. On a failed office lookup the operation is silently dropped. + +The administrator view draws the checkbox directly from the bitmap (reads at +`0x005D9D98` and `0x005DD987`, passing the player merchant global `operations+0x924` +and the window's town). diff --git a/src/operations/0068-set-trade-route-active.md b/src/operations/0068-set-trade-route-active.md new file mode 100644 index 0000000..ef7ca97 --- /dev/null +++ b/src/operations/0068-set-trade-route-active.md @@ -0,0 +1,20 @@ +# Set Trade Route Active +Operation `0x68` activates or deactivates a ship's trade route - the route panel's +"active" checkbox. + +|Offset|Type|Value| +|-|-|-| +|0x00|u32|opcode `0x68`| +|0x04|u32|ship index| +|0x08|u32|active: 0 deactivates, anything else activates| + +The handler at `0x0053DF00` (operation switch case `0x5364C5`) validates the ship +index, resolves the convoy, and updates the route state flags on the ship (`+0x136`, +`+0x3D`) or convoy. Deactivating also resets the current destination to the last +visited town. + +`transfer_loaded_traderoute` (`0x005492D0`) enqueues the deactivation as its first +step when replacing a ship's route (`0x005494DD`). + +Deactivating does not remove any stops; see +[Trade Route Stop Town Change](./006a-trade-route-stop-town-change.md) for that. diff --git a/src/operations/0069-route-stop-setting-change.md b/src/operations/0069-route-stop-setting-change.md new file mode 100644 index 0000000..0c08711 --- /dev/null +++ b/src/operations/0069-route-stop-setting-change.md @@ -0,0 +1,27 @@ +# Route Stop Setting Change +Operation `0x69` changes one ware's instruction of an applied trade route stop: the +amount and the price, in the stop record encoding of the [.rou format](../file-formats/rou.md). +It is enqueued by the "Automatic maritime trading" dialog (the route window's Goods +button): its +/- buttons keep edits pending in the dialog object and commit them +through this operation when the edited ware changes, the stop is switched, or the +dialog closes - which is also why the dialog's Undo only covers edits since the last +such commit. + +|Offset|Type|Value| +|-|-|-| +|0x00|u32|opcode `0x69`| +|0x04|u16|route stop pool index| +|0x06|u16|merchant index| +|0x08|u16|ware index| +|0x0A|u16|instruction slot in the stop's ware order array| +|0x0C|i32|amount, raw units; negative = ship to office; `1_000_000_000` = Max| +|0x10|i32|price: positive = sell minimum, negative = buy maximum, 0 = office transfer| + +The handler at `0x0053E480` (operation switch case `0x5364D4`) writes the values into +the stop's record in the route stop pool at `[0x006DD72C]`. + +The dialog also uses this operation to normalize a stop's inactive slots after opening: +slots without an instruction (amount 0) can carry leftover base prices, and the dialog +enqueues one operation per such ware to zero them, drained over the following ticks. +Code reading a stop record must therefore treat `amount != 0` as the "slot has an +instruction" test - the price alone can be a stale base price for a while. diff --git a/src/operations/006a-trade-route-stop-town-change.md b/src/operations/006a-trade-route-stop-town-change.md new file mode 100644 index 0000000..f5a603a --- /dev/null +++ b/src/operations/006a-trade-route-stop-town-change.md @@ -0,0 +1,27 @@ +# Trade Route Stop Town Change +Operation `0x6A` inserts a stop into an applied trade route or removes one - the route +panel's town selection, where choosing "none" removes the stop. Stops are identified by +their index in the route stop pool (see [Trade Routes (.rou)](../file-formats/rou.md)), +not by their position in the route. + +|Offset|Type|Value| +|-|-|-| +|0x00|u32|opcode `0x6A`| +|0x04|u32|stop pool index (validated against the pool count `[0x006DD72A]`)| +|0x08|u32|insert flag: 0 = remove this stop, 1 = insert a new stop after it| +|0x0C|u32|town index; `0xFF` ("none") on removal| +|0x10|u32|ship index| + +The handler at `0x0053E610` (operation switch case `0x5364E3`): + +- **Insert** (`+0x08` set): validates the town, allocates a fresh pool record through + the pool allocator (`0x004D4C90`, `this = 0x006DD728`), links it into the chain after + the record at `+0x04` and sets its town. The record's instructions start empty. +- **Remove** (`+0x08` zero, town `0xFF`): moves the first-stop marker (action bit + `0x04`) to the successor if the removed stop carried it, retargets ships heading for + the removed stop (`0x00509030`), and releases the record through the pool free + (`0x004D4E80`). + +Freed pool records are reused by a freelist: removing a stop and adding a new one +hands out the same index again (verified in-game), which is why stop identity must +always be taken from the live chain. diff --git a/src/ships.md b/src/ships.md index c40f9bf..32784b3 100644 --- a/src/ships.md +++ b/src/ships.md @@ -82,7 +82,7 @@ The following fields have been identified: 00000128 int field_128; 0000012C int field_12C; 00000130 __int16 field_130; -00000132 unsigned __int16 field_132; +00000132 unsigned __int16 field_132_route_stop_index; // current stop in the trade route stop pool, see Trade Routes (.rou) 00000134 __int16 field_134_status; 00000136 char field_136; 00000137 char field_137; diff --git a/src/towns.md b/src/towns.md index 88a65c3..dd2cabf 100644 --- a/src/towns.md +++ b/src/towns.md @@ -155,7 +155,7 @@ The following fields have been identified: 000003CC field_3CC dd ? 000003D0 field_3D0_wares_copy dd 24 dup(?) 00000430 field_430_unknown_wares_data dd 24 dup(?) -00000490 field_490_weird_prods dd 24 dup(?) +00000490 field_490_daily_production dd 24 dup(?) ; raw units/day; t2 = t1 + 10 days of this; nonzero exactly for the wares the town produces, but does not match the market hall's fixed weekly production 000004F0 field_4F0_consumption_data consumption_data 24 dup(?) 00000670 field_670 dd ? 00000674 field_674 dd ? diff --git a/src/towns/ware-prices/selling-price.md b/src/towns/ware-prices/selling-price.md index ecf7736..0a1fad8 100644 --- a/src/towns/ware-prices/selling-price.md +++ b/src/towns/ware-prices/selling-price.md @@ -45,6 +45,8 @@ and \\(d\_{trade\\_difficulty}\\) is defined as: |1 (normal)|2.0| |2 (high)|1.8| +The mapped value is held as a float at `0x006DE43C`, field `+0x64` of the static settings class at `0x006DE3D8` that `get_sell_price` callers pass as `this`. + ## Example Let's assume we sell pig iron to a town with the following thresholds: diff --git a/src/towns/ware-prices/thresholds.md b/src/towns/ware-prices/thresholds.md index cc7bbac..f482efb 100644 --- a/src/towns/ware-prices/thresholds.md +++ b/src/towns/ware-prices/thresholds.md @@ -1,7 +1,6 @@ # Thresholds A town's price thresholds are updated by the `update_town_price_thresholds` function at `0x00528070` every time the town ticks. -The calculation is partially understood, but some aspects are still to be determined. The following pseudocode denotes what is known: ```rust fn update_town_price_thresholds(town) { @@ -134,17 +133,21 @@ fn update_town_price_thresholds(town) { } */ - // Set t2 and t3 except for bricks and weapons + // Set t2 and t3 except for bricks and weapons: t2 adds ten days of the town's + // production array (town+0x490, raw units/day; verified in-game across several + // towns via t2 - t1). The array is nonzero exactly for the wares the town + // produces, but its magnitude does NOT match the market hall's fixed weekly + // production - what exactly it measures is still open. for i in 0..19 { - thresholds[i][2] = thresholds[i][1] + 10 * town.unidentified_array[i]; + thresholds[i][2] = thresholds[i][1] + 10 * town.daily_production[i]; thresholds[i][3] = thresholds[i][2] + thresholds[i][0]; } // Pitch and bricks production bonus - if town.production[WareId::Pitch] > 0 { + if town.daily_production[WareId::Pitch] > 0 { thresholds[WareId::Pitch][3] += 3600; } - if town.production[WareId::Bricks] > 0 { + if town.daily_production[WareId::Bricks] > 0 { thresholds[WareId::Bricks][3] += 160000; } @@ -156,8 +159,8 @@ fn update_town_price_thresholds(town) { // Bricks t2 and t3 if has_effective_bricks_production { - thresholds[WareId::Bricks][2] = thresholds[WareId::Bricks][1] + town.unidentified_array[WareId::Bricks]; - thresholds[WareId::Bricks][3] = thresholds[WareId::Bricks][1] + 2 * town.unidentified_array[WareId::Bricks]; + thresholds[WareId::Bricks][2] = thresholds[WareId::Bricks][1] + town.daily_production[WareId::Bricks]; + thresholds[WareId::Bricks][3] = thresholds[WareId::Bricks][1] + 2 * town.daily_production[WareId::Bricks]; } else if thresholds[WareId::Bricks][2] > 2 * thresholds[WareId::Bricks][1] { // TODO: can this every be true? thresholds[WareId::Bricks][2] = 2 * thresholds[WareId::Bricks][1]; diff --git a/src/ui.md b/src/ui.md new file mode 100644 index 0000000..3c3bbde --- /dev/null +++ b/src/ui.md @@ -0,0 +1,80 @@ +# UI +P3's user interface is built from window objects sharing a common class family, managed +by a central window manager. This chapter collects what has been reverse engineered +about the framework and individual windows. + +## Window Objects +Most windows are constructed once at startup by a mass-constructor around `0x00426000` +and live for the whole session; "opening" and "closing" only registers and deregisters +them with the window manager. Many hold their object pointer in a static: + +|Static|Window| +|-|-| +|`0x006E5500`|town hall side menu| +|`0x006E557C`|trading office window| +|`0x006E558C`|town hall window| +|`0x006E55C0`|shipyard window| +|`0x006CBA74`|auto trade goods dialog ("Automatic maritime trading")| + +About twenty more statics in the `0x006E5500`-`0x006E55D0` cluster hold further +windows, each written exactly once by its constructor. Not every UI object has a +static: the scrollmap's trade route panel, for example, is only reachable through its +vtable (see [Trade Route Panel](./ui/trade-route-panel.md)). + +## Window Class Family +The window classes share their vtable layout. Two slots are load-bearing for modding: + +|Vtable slot|Method| +|-|-| +|`+0x118`|close: deregister from the window manager, hide| +|`+0x120`|open: register with the window manager, build/populate the widgets| + +Verified for the trading office window, the town hall window and the goods dialog +(base class vtable `0x0066BC90`, base open `0x00462390`). Hooking these slots is the +established way to track a window's open state (used by +`mod-trading-office-prices-synchronization` and `mod-auto-supply`). + +## Window Manager +A singleton reachable through `0x004B9730` (`this = 0x006DA5F0`) tracks the open +windows: `0x004B4E30` registers a window, `0x004B4EB0` deregisters it. Windows +register their embedded sub-windows too. Calling a window's open method on an +already-open window registers it twice - it then draws twice and needs two closes - +so programmatic refreshes must not re-run open (see +[Trading Office Window](./ui/trading-office-window.md) for the working alternative). + +## Number Widgets +The numeric row widgets (amounts, prices) cache their displayed value and text. The +setter at `0x0045C930` (thiscall, one argument) clamps the value to the widget's +bounds at `+0x180`/`+0x184`, stores it at `+0x188`, flags `+0x18C` dirty and rewrites +the label text. In-place writes to the underlying data are invisible until either this +setter runs or the owning window repopulates. + +## String Objects +Several game functions take an MFC-style string object instead of a plain C string: a +single pointer to character data whose header (refcount, allocated size, length) sits +in the 12 bytes before the data. Passing a raw `char*` to such a function crashes - +the callee dereferences the characters as a pointer. + +- construct/assign from a C string: `0x0064F390` (thiscall(this, char*)) +- destruct: `0x0064F253` (thiscall(this)) +- `[0x006C7CCC]`/`[0x006C7CD0]` hold the shared empty-string sentinel; a fresh object + should be initialized to `[0x006C7CD0] + 0xC` so the constructor's release-old-data + path is a no-op. + +The trade route file loader (`0x004D5EE0`, see +[Trade Routes (.rou)](./file-formats/rou.md)) is one such consumer. + +## Render Imports +Drawing goes through the game's own render DLL, `ddraw_Dll.dll` (shipped in the +game directory, distinct from the system's `ddraw.dll`). Its exports are bound at +startup into a function-pointer table in BSS around `0x006DA9F0`-`0x006DAA10`, +and the game calls them through a block of trampolines at `0x004BB3E0` onward, +one `jmp [pointer]` each - e.g. `0x004BB3F0` is `jmp [0x006DAA04]`, the text +draw. + +That text draw (`ddraw_Dll+0xF100`, cdecl, fourth argument the C string) begins +with `cmp byte [string], 0` - no validity check of any kind - so any bad string +pointer the game passes crashes *inside* `ddraw_Dll`. A crash address in +`ddraw_Dll` therefore usually means bad arguments from game code, not a render +bug; the caller is on the stack right above (see the +[patrol letter crash](./bugs/patrol-letter-crash.md) for a worked example). diff --git a/src/ui/auto-trade-goods-dialog.md b/src/ui/auto-trade-goods-dialog.md new file mode 100644 index 0000000..843b339 --- /dev/null +++ b/src/ui/auto-trade-goods-dialog.md @@ -0,0 +1,37 @@ +# Auto Trade Goods Dialog +The "Automatic maritime trading in ..." dialog edits one stop of an applied trade +route. Its object is held in the static `0x006CBA74` (constructor `0x00403020`, vtable +`0x0066A7F0`, base class vtable `0x0066BC90`). + +|Field|Meaning| +|-|-| +|`+0xA4`|pool index of the displayed stop; `-1` while the dialog is closed (the close method `0x004066F0` guards on it and stores `-1`)| +|`+0xA8`|ship index| +|`+0x4AE0`|per-ware mode array: 0 load, 1 sell, 3 buy, 4 none| +|`+0x4ADC`|ware index of the pending (uncommitted) edit, `-1` = none| +|`+0x547C`|flag byte, third argument of populate| + +Per-ware widget structs follow at stride `0x190`: `+0x8E0` holds the entered amount +(`-1` encodes Max, substituted with `1_000_000_000` on commit), `+0x2698` a text +buffer that is `atoi`'d and scaled by the barrel/bundle table at `0x00672C14`. + +`populate` (`0x00405A20`, thiscall(this, stop_pool_index, ship_index, flag)) rebuilds +the whole dialog from the stop record. It is called by the route panel's Goods button +(`0x0048C432`) and by the dialog's own stop-switching arrows, which follow the pool +chain from `+0xA4` (`0x004075E3` next, `0x0040763A` previous). The displayed texts are +sprintf-cached in the object, so in-place writes to the pool record stay invisible +until populate runs again. + +## Deferred Commit and Undo +The +/- buttons do not write the stop record directly: they update the widget texts and +keep the edit pending (`+0x4ADC`). A commit helper around `0x00405461` builds +[operation 0x69](../operations/0069-route-stop-setting-change.md) from the per-ware +fields when the edit target changes, the stop is switched, or the dialog closes. + +Undo therefore does not restore a snapshot - it discards the pending edits by +re-reading the pool record, which is why it reverts everything (order type included) +and why it does nothing after a stop switch: the switch committed. + +On opening, the dialog normalizes the stop's inactive slots (amount 0, but possibly a +leftover base price) by enqueueing one operation 0x69 per such ware; these drain over +the following ticks. diff --git a/src/ui/name-banks.md b/src/ui/name-banks.md new file mode 100644 index 0000000..79a2da6 --- /dev/null +++ b/src/ui/name-banks.md @@ -0,0 +1,26 @@ +# Name Banks +Three consecutive 40-slot string-pointer banks in BSS hold localized name lists, +loaded from one text blob of consecutive NUL-terminated strings: + +|Bank|Address|Content| +|-|-|-| +|A|`0x006DD8C0`|tavern names| +|B|`0x006DD960`|guild names| +|C|`0x006DDA00`|town names| + +The populate function at `0x00512730` (thiscall; this = loader object: `+0` blob +start, `+4` blob end, `+8` per-slot pointer array) fills bank C's 40 slots first, +then bank B's, then bank A's, walking the blob string by string. When the list +has fewer entries than slots (a 24-town map, for example), the remaining slots +keep pointing at the blob's first string. Further writers at `0x00511E76`, +`0x00511EB1`, `0x005127FC` and `0x0051288C` repopulate banks at runtime from a +cache object. + +Directly after bank C sits the full town-name table object at `0x006DDAA0`, whose +pointer array at `+0x8` covers all 40 towns; `0x00512B20` (thiscall(this = +`0x006DDAA0`, town)) returns a town's name and is what the text formatters use. + +Bank consumers index the 40 slots directly, e.g. +`mov eax, [index*4 + 0x006DDA00]` - usually with an index that is a genuine town +byte. The letters list does it with an unvalidated byte, which is the +[patrol letter crash](../bugs/patrol-letter-crash.md). diff --git a/src/ui/personal-letters-window.md b/src/ui/personal-letters-window.md new file mode 100644 index 0000000..a6f43db --- /dev/null +++ b/src/ui/personal-letters-window.md @@ -0,0 +1,40 @@ +# Personal Letters Window +The letters window (envelope button; "Personal letters", "Trade", etc. tabs) +lists the player's [messages](../letters.md). Its object pointer is held in the +static `0x006CBD90`. + +|Field|Meaning| +|-|-| +|`+0xA0`|pointer to the row model (heap; freed and rebuilt on tab switches)| +|`+0xCC`|row count (u16)| +|`+0xD2`|selected row (u16), `0xFFFF` = none| +|`+0xD4`|current tab (u16), clamped to `0..3`| + +## Row Model +The row model is an array of 20-byte rows built by `0x0047C520`, which walks the +player's mailbox chain and keeps the messages whose category matches the current +tab (category = byte table `0x006C0198` indexed by message type). `0x0047C820` +(thiscall(this, tab, force)) switches tabs and rebuilds. Row layout: + +|Offset|Meaning| +|-|-| +|`+0x0`|message pool index (u16); `0xFFFF` terminates the array| +|`+0x2`|message type| +|`+0x4`|title id: a copy of the message's town byte| +|`+0x6`|unread flag (bit 7 of the message's `+0x1`)| +|`+0x8`|the date as text, `dd.mm.yyyy`| + +## Row Draw +Each row draws the date string, the type name, and the town column. The town +column is looked up as `[0x006DDA00 + 4*title_id]` (`0x0047D928`) - the town-name +[name bank](./name-banks.md) - **without a bounds check**, and the resulting +pointer goes to the render DLL's text draw, which dereferences it unguarded (see +[Render Imports](../ui.md#render-imports)). Messages whose town byte is not a +town index make this read past the bank into unrelated globals: the row then +shows a wrong town, shows nothing, or crashes the game, depending on the value it +hits - the [patrol letter crash](../bugs/patrol-letter-crash.md). + +The unread flag selects the row color: black (`0xFF000000`) for unread, brown +(`0xFF5A2406`) for read. The scripted letter types `0x3C..0x40` derive their row +icon from the message's `+0xC` payload; other types index the icon table at +`0x006A52D0` by type. diff --git a/src/ui/trade-route-panel.md b/src/ui/trade-route-panel.md new file mode 100644 index 0000000..cea9f8c --- /dev/null +++ b/src/ui/trade-route-panel.md @@ -0,0 +1,24 @@ +# Trade Route Panel +The scrollmap's right-side panel showing the selected ship or convoy and its trade +route. The object has no static pointer; it can be captured by hooking the vtable slot +at `0x0066F44C` (module offset `0x26F44C`), which holds the panel's per-frame update +method `0x0048B3E0` - the hook receives the object as `this` on every update. + +|Field|Meaning| +|-|-| +|`+0xA0`|pointer to the current selection; the selection's first u16 is the selected ship index| +|`+0xA00`|stop row widget structs, stride `0xE8`; `row + 0x3E` is set while that row's stop is open in the goods dialog via its Goods button| + +The selection pointer at `+0xA0` is what the panel's own code uses (`0x0048C363`, and +the route Load handler at `0x0048C92E` when filling `operations + 0x934`), making it a +reliable source for "which ship is selected" - it works on the world map and in town, +for own and foreign ships alike. + +The panel's Goods button computes the clicked row's stop by walking the pool chain +from `ship + 0x132` to the first-stop marker and forward by the row number +(`0x0048C3A1`), then calls the +[goods dialog](./auto-trade-goods-dialog.md)'s populate (`0x0048C432`). + +Route edits made through the panel (town selection, "none", the active checkbox) are +operations: see [Set Trade Route Active](../operations/0068-set-trade-route-active.md) +and [Trade Route Stop Town Change](../operations/006a-trade-route-stop-town-change.md). diff --git a/src/ui/trading-office-window.md b/src/ui/trading-office-window.md new file mode 100644 index 0000000..a3cbeda --- /dev/null +++ b/src/ui/trading-office-window.md @@ -0,0 +1,42 @@ +# Trading Office Window +The trading office window object is held in the static `0x006E557C`; its vtable is at +`0x00679CB0` (module offset `0x279CB0`). + +|Field|Meaning| +|-|-| +|`+0xECC4`|selected page, 0-6 (4 = the administrator "Trading Office" view)| +|`+0xECC8`|town index, copied from the town scene on open (`0x005D8E3A`)| + +`select_new_page` (`0x005D9A20`, thiscall(this, page)) switches the side menu page and +rebuilds the direction arrows and price displays of the administrator view - but not +the amount displays. + +## Administrator Amount Rows +The administrator view's per-ware rows are widget structs at +`window + 0x9840 + row * 0x190`, rows in the ware display order of the table at +`0x00698538` (identity in the executable, sorted at runtime by localized ware name). +Each row embeds a [number widget](../ui.md#number-widgets): the displayed amount lives +at `row + 0x188` and is written through the setter `0x0045C930`. + +The amounts are populated only by the window's open method (`0x005D8950`, +vtable `+0x120`): its 20-ware loop at `0x005D8F40` reads the office stock +(`office + 0x354`), divides by the ware scaling (barrels 200, bundles 2000, via the +scaling table at `0x00672C14`), clamps to 9999 and calls the widget setter. This is +why administrator amounts historically refreshed only when the window was reopened; +a mod can refresh them in place by re-running the same computation against the row +widgets. + +Re-running the open method itself repopulates everything but registers the window +family with the [window manager](../ui.md#window-manager) a second time; pairing it +with the close method (`0x005D92C0`, vtable `+0x118`) balances the registration but +detaches the side menu - the game's real open path goes through a view controller +above the window. + +## Lock Checkbox +The per-ware "Lock min. store quantity for auto trade ships" checkbox is drawn +directly from the office lock bitmap (`office + 0x3B4`): the draw code at +`0x005D9D98`/`0x005DD987` resolves the office through the lookup at `0x005308A0`, +passing the player merchant global (`operations + 0x924` = `0x006DFC14`) and the +window's town - establishing that lookup's argument order as (merchant, town). Toggling +the checkbox goes through +[operation 0x66](../operations/0066-office-autotrade-lock-change.md). From 24016c20042ef95315612fbb18870c5685f538ca Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 19 Aug 2026 23:35:48 +0300 Subject: [PATCH 2/5] weirdness solved --- src/towns.md | 2 +- src/towns/ware-prices/thresholds.md | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/towns.md b/src/towns.md index dd2cabf..f2ac6ca 100644 --- a/src/towns.md +++ b/src/towns.md @@ -155,7 +155,7 @@ The following fields have been identified: 000003CC field_3CC dd ? 000003D0 field_3D0_wares_copy dd 24 dup(?) 00000430 field_430_unknown_wares_data dd 24 dup(?) -00000490 field_490_daily_production dd 24 dup(?) ; raw units/day; t2 = t1 + 10 days of this; nonzero exactly for the wares the town produces, but does not match the market hall's fixed weekly production +00000490 field_490_daily_production dd 24 dup(?) ; raw units/day at FULL utilization (nominal capacity, independent of facility staffing); t2 = t1 + 10 days of this; nonzero exactly for the wares the town produces. The market hall window shows actual staffing-scaled output instead, which is why the two differ (verified: halving a sawmill's utilization halved the window's number, not this array) 000004F0 field_4F0_consumption_data consumption_data 24 dup(?) 00000670 field_670 dd ? 00000674 field_674 dd ? diff --git a/src/towns/ware-prices/thresholds.md b/src/towns/ware-prices/thresholds.md index f482efb..38249f5 100644 --- a/src/towns/ware-prices/thresholds.md +++ b/src/towns/ware-prices/thresholds.md @@ -135,9 +135,12 @@ fn update_town_price_thresholds(town) { // Set t2 and t3 except for bricks and weapons: t2 adds ten days of the town's // production array (town+0x490, raw units/day; verified in-game across several - // towns via t2 - t1). The array is nonzero exactly for the wares the town - // produces, but its magnitude does NOT match the market hall's fixed weekly - // production - what exactly it measures is still open. + // towns via t2 - t1). The array holds NOMINAL production at full utilization: + // it is nonzero exactly for the wares the town produces and does not react to + // facility staffing, while the market hall window shows the actual + // staffing-scaled output (verified: halving a sawmill's utilization halved the + // window's number but not the array). Thresholds therefore anchor to what the + // town COULD produce, not to what it currently does. for i in 0..19 { thresholds[i][2] = thresholds[i][1] + 10 * town.daily_production[i]; thresholds[i][3] = thresholds[i][2] + thresholds[i][0]; From 93b7c2db54f4126747908236d3b194cb43af6378 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 20 Aug 2026 00:37:30 +0300 Subject: [PATCH 3/5] notifications --- src/SUMMARY.md | 1 + src/letters/scripted-letters.md | 18 ++++++++------ src/towns.md | 2 +- src/towns/ware-prices/thresholds.md | 15 ++++++++---- src/ui/name-banks.md | 10 +++++--- src/ui/notification-tickers.md | 38 +++++++++++++++++++++++++++++ src/ui/personal-letters-window.md | 6 ++--- 7 files changed, 71 insertions(+), 19 deletions(-) create mode 100644 src/ui/notification-tickers.md diff --git a/src/SUMMARY.md b/src/SUMMARY.md index 52840b3..e7a6354 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -69,6 +69,7 @@ - [Auto Trade Goods Dialog](./ui/auto-trade-goods-dialog.md) - [Trade Route Panel](./ui/trade-route-panel.md) - [Personal Letters Window](./ui/personal-letters-window.md) + - [Notification Tickers](./ui/notification-tickers.md) - [Name Banks](./ui/name-banks.md) - [Letters](./letters.md) - [Scripted Letters](./letters/scripted-letters.md) diff --git a/src/letters/scripted-letters.md b/src/letters/scripted-letters.md index 7719b0c..2045b2a 100644 --- a/src/letters/scripted-letters.md +++ b/src/letters/scripted-letters.md @@ -16,17 +16,21 @@ command's operands (`cmd[n]` below) and the script variables (`var[n]`): |descriptor (`+0x8`)|`malloc(0x10)`, filled from `cmd[3..6]` and further variables| |text (`+0xC`)|`malloc(0x1000)`, the formatted letter body| -The scripted letter types `0x3C..0x40` are the ones the letters list treats -specially (payload-based icon instead of the type icon table `0x006A52D0`). +The scripted letter types `0x3C..0x40` are the ones the letters list and the +[notification tickers](../ui/notification-tickers.md) treat specially: their +display name comes from the letter-text payload instead of the type-name string +table at `0x006A52D0`. ## Text Formatting The letter body is built by a `%`-substitution engine inside the same interpreter: it copies the template text, expanding placeholders from replacement -string tables (e.g. `0x006C3040`) and game data. Town names are resolved through -the helper `0x00512B20` against the full town-name table (see -[Name Banks](../ui/name-banks.md)), so the letter text names towns correctly even -when the message's town byte does not hold a town - the two come from different -places. +string tables (e.g. `0x006C3040`) and game data - among them the dynamic-name +resolver `0x00512B20` (ship and similar names, see +[Name Banks](../ui/name-banks.md)). The names in the letter text are resolved +from their own sources at creation time, so the text is correct even when the +message's town byte does not hold a town - the two come from different places. +Descriptor`+0x0` holds the finished text's length (the creation code tracks the +raw write position there while building), which bounds the text. ## Sending The handler ends in [add_message](../letters.md) calls: to a single recipient diff --git a/src/towns.md b/src/towns.md index f2ac6ca..3c176da 100644 --- a/src/towns.md +++ b/src/towns.md @@ -155,7 +155,7 @@ The following fields have been identified: 000003CC field_3CC dd ? 000003D0 field_3D0_wares_copy dd 24 dup(?) 00000430 field_430_unknown_wares_data dd 24 dup(?) -00000490 field_490_daily_production dd 24 dup(?) ; raw units/day at FULL utilization (nominal capacity, independent of facility staffing); t2 = t1 + 10 days of this; nonzero exactly for the wares the town produces. The market hall window shows actual staffing-scaled output instead, which is why the two differ (verified: halving a sawmill's utilization halved the window's number, not this array) +00000490 field_490_daily_production dd 24 dup(?) ; raw units/day at FULL utilization (nominal capacity; facilities count by existence, staffing ignored - verified down to 0% utilization); t2 = t1 + 10 days of this; nonzero exactly for the wares the town produces. The market hall window shows actual staffing-scaled output instead, which is why the two differ 000004F0 field_4F0_consumption_data consumption_data 24 dup(?) 00000670 field_670 dd ? 00000674 field_674 dd ? diff --git a/src/towns/ware-prices/thresholds.md b/src/towns/ware-prices/thresholds.md index 38249f5..4dfd5d8 100644 --- a/src/towns/ware-prices/thresholds.md +++ b/src/towns/ware-prices/thresholds.md @@ -136,11 +136,16 @@ fn update_town_price_thresholds(town) { // Set t2 and t3 except for bricks and weapons: t2 adds ten days of the town's // production array (town+0x490, raw units/day; verified in-game across several // towns via t2 - t1). The array holds NOMINAL production at full utilization: - // it is nonzero exactly for the wares the town produces and does not react to - // facility staffing, while the market hall window shows the actual - // staffing-scaled output (verified: halving a sawmill's utilization halved the - // window's number but not the array). Thresholds therefore anchor to what the - // town COULD produce, not to what it currently does. + // it is nonzero exactly for the wares the town produces and ignores facility + // staffing completely, while the market hall window shows the actual + // staffing-scaled output (verified: dropping a sawmill to 50% and then 0% + // utilization halved and then zeroed the window's number; the array never + // moved). Thresholds therefore anchor to what the town COULD produce, not to + // what it currently does - with a tradeable consequence: building production + // facilities and leaving them unstaffed still deepens t2 (and t3), stretching + // the price curve's oversupply segment, so the town tolerates much larger + // stockpiles of that ware before its prices collapse toward the floor. Every + // merchant's facilities count, AI-owned included. for i in 0..19 { thresholds[i][2] = thresholds[i][1] + 10 * town.daily_production[i]; thresholds[i][3] = thresholds[i][2] + thresholds[i][0]; diff --git a/src/ui/name-banks.md b/src/ui/name-banks.md index 79a2da6..fccfc5e 100644 --- a/src/ui/name-banks.md +++ b/src/ui/name-banks.md @@ -16,9 +16,13 @@ keep pointing at the blob's first string. Further writers at `0x00511E76`, `0x00511EB1`, `0x005127FC` and `0x0051288C` repopulate banks at runtime from a cache object. -Directly after bank C sits the full town-name table object at `0x006DDAA0`, whose -pointer array at `+0x8` covers all 40 towns; `0x00512B20` (thiscall(this = -`0x006DDAA0`, town)) returns a town's name and is what the text formatters use. +Directly after bank C, at `0x006DDAA8`, sit 40 consecutive town-name pointers +covering all towns of the full map - fields of a larger name-registry object at +`0x006DDAA0`. That object's getter `0x00512B20` (thiscall(this, id)) is NOT a +town lookup: it resolves ids through two offset-table sections (`this+0xC8`/ +`+0xCC` into blobs at `this+0xB4`/`+0xB8`, section limits `this+0xD8`/`+0xDA`) +and returns dynamic names - id 15 resolved to a ship name in testing. Town names +by savegame town index come from bank C. Bank consumers index the 40 slots directly, e.g. `mov eax, [index*4 + 0x006DDA00]` - usually with an index that is a genuine town diff --git a/src/ui/notification-tickers.md b/src/ui/notification-tickers.md new file mode 100644 index 0000000..85d6563 --- /dev/null +++ b/src/ui/notification-tickers.md @@ -0,0 +1,38 @@ +# Notification Tickers +The popup boxes on the scrollmap - events on the top left ("Game speed: ..."), +incoming-letter notices on the top right ("Trading information: ...") - are two +queues on one manager object, held in the static `0x006CBB40`. + +|Field|Meaning| +|-|-| +|`+0x168`|left-queue slot widgets, stride `0xA0`, text object at slot `+0x9C`| +|`+0x488`|left-queue count (byte, capacity 5; enqueue bails when full)| +|`+0x494`|right-queue slots, stride `0xA0`| +|`+0x7B4`|right-queue count (byte, capacity 5)| +|`+0x7D0`|left-queue expiry ticks, one u32 per slot: enqueue tick + `0x2EE0`| + +## Posting +- Left/event popup: `0x0042B6A0` (thiscall(this, text)) - takes a **plain C + string** and does everything: picks the slot, sets the text, stamps the expiry + (12000 ticks from `[0x006DCCF0]`, the tick counter). Anything can post one. +- Right/letter popup: `0x0042BB20` (thiscall(this, string)), followed by a + `0x004237D0` refresh - what the letter announcer uses. + +## The Letter Announcer +When a letter is delivered to the player, the mailbox insert (`0x004D6680`) +dispatches by category (byte table `0x006C0198[type]`, jump table `0x004D6760`) +and, gated by the mailbox's notification settings, calls the announcer +`0x004D7B10`: + +- mailbox `+0x26` is the notification bitmask (the in-game message options): + bits 0-2 play the arrival sound per category (`0x00443150`, sound id `0x1770`), + bits 3-5 post the popup per category. +- The popup text is `sprintf(template, type_name)`: templates at + `[0x006A4570]`/`[0x006A4574]`/`[0x006A4578]` per category ("Trading + information: %s", ...), type names from the string table at `0x006A52D0` + indexed by message type; the scripted letter types `0x3C..0x40` use their + letter-text payload instead. +- mailbox `+0x38` (values interpreted through the table at `0x006C0220`) gates + whether the announcer runs at all. + +See [Letters](../letters.md) for the message structures. diff --git a/src/ui/personal-letters-window.md b/src/ui/personal-letters-window.md index a6f43db..3cc8e04 100644 --- a/src/ui/personal-letters-window.md +++ b/src/ui/personal-letters-window.md @@ -35,6 +35,6 @@ shows a wrong town, shows nothing, or crashes the game, depending on the value i hits - the [patrol letter crash](../bugs/patrol-letter-crash.md). The unread flag selects the row color: black (`0xFF000000`) for unread, brown -(`0xFF5A2406`) for read. The scripted letter types `0x3C..0x40` derive their row -icon from the message's `+0xC` payload; other types index the icon table at -`0x006A52D0` by type. +(`0xFF5A2406`) for read. The middle column is the message's type name from the +string table at `0x006A52D0` (indexed by type); the scripted letter types +`0x3C..0x40` show their letter-text payload (`+0xC`) instead. From bbfe26c82b6794058eaf1c23a09b042fc737238d Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 20 Aug 2026 02:05:00 +0300 Subject: [PATCH 4/5] ship rename op --- src/SUMMARY.md | 1 + src/operations/002d-rename-ship.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 src/operations/002d-rename-ship.md diff --git a/src/SUMMARY.md b/src/SUMMARY.md index e7a6354..37e0dee 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -11,6 +11,7 @@ - [Ship Types](./basics/ships.md) - [Ship Artillery](./basics/ship-artillery.md) - [Operations](./operations.md) + - [Rename Ship](./operations/002d-rename-ship.md) - [Join Guild](./operations/0037-join-guild.md) - [Bath House Bribe Success](./operations/0042-bath-house-bribe-success.md) - [Bath House Bribe Failure](./operations/0043-bath-house-bribe-failure.md) diff --git a/src/operations/002d-rename-ship.md b/src/operations/002d-rename-ship.md new file mode 100644 index 0000000..1151f07 --- /dev/null +++ b/src/operations/002d-rename-ship.md @@ -0,0 +1,29 @@ +# Rename Ship +Operation `0x2D` renames a ship - the shipyard's "change name" button. Operation +`0x2E` appends to the name: the shipyard sender (`0x005FAF5D`) chunks the entered +name by 12 characters, sending the first chunk as `0x2D` and every further chunk +as `0x2E` (its text field allows 15 characters total). Both share the layout: + +|Offset|Type|Value| +|-|-|-| +|0x00|u32|opcode `0x2D` (set) / `0x2E` (append)| +|0x04|char[12]|name chunk, latin1, NUL-padded| +|0x10|u32|ship index| + +The switch case (`0x00535CAA`) forwards to a handler at `0x0053CCE0` that is shared +by a family of rename operations. For `0x2D` it copies exactly 12 name bytes into a +stack buffer (forcing a NUL terminator after them - names are at most 12 +characters), bounds-checks the ship index against the ship count (`0x006DD894`, +ships array `[0x006DD7A4]`, stride `0x180`), and assigns the name through the +dynamic-name registry (`0x00512D40`, this = `0x006DDAA0`, see +[Name Banks](../ui/name-banks.md)) targeting `ship+0x15E` - the ship's registry id +word. That call keeps both the registry (which letter texts resolve ship names +from) and the ship's inline name buffer at `+0x160` in sync. The `0x2E` branch of +the same handler appends its chunk to the ship's existing registry name instead +(via `0x00512FC0`) rather than replacing it. + +Captured example, renaming ship `0x60` to "Hunter 31": + +``` +2d 00 00 00 48 75 6e 74 65 72 20 33 31 00 00 00 60 00 00 00 +``` From 03b3b618018961d1228c087d6ac54c0a98a266d1 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 21 Aug 2026 16:08:29 +0300 Subject: [PATCH 5/5] missions decoding --- src/SUMMARY.md | 3 + src/auto-traders.md | 79 +++++++++- src/bugs/patrol-letter-crash.md | 12 +- src/bugs/tavern-mission-lock-leak.md | 75 ++++++++++ src/ch05-04-sailor-pools.md | 17 +++ src/letters/71-tavern-missions.md | 120 +++++++++++++++ src/letters/mission-scripts.md | 119 +++++++++++++++ src/letters/scripted-letters.md | 11 +- src/operations/0052-tavern-interaction.md | 79 +++++++++- src/ships.md | 39 +++++ src/towns.md | 3 +- src/towns/tavern.md | 9 ++ src/ui.md | 175 +++++++++++++++++++++- 13 files changed, 727 insertions(+), 14 deletions(-) create mode 100644 src/bugs/tavern-mission-lock-leak.md create mode 100644 src/letters/71-tavern-missions.md create mode 100644 src/letters/mission-scripts.md diff --git a/src/SUMMARY.md b/src/SUMMARY.md index 37e0dee..80ea22f 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -74,6 +74,8 @@ - [Name Banks](./ui/name-banks.md) - [Letters](./letters.md) - [Scripted Letters](./letters/scripted-letters.md) + - [Mission Scripts](./letters/mission-scripts.md) + - [Tavern Missions](./letters/71-tavern-missions.md) - [Charge](./letters/0a-charge.md) - [Indictment](./letters/19-indictment.md) - [Multiplayer](./multiplayer.md) @@ -90,6 +92,7 @@ - [Multiplayer Locks](./bugs/multiplayer-locks.md) - [Uncompressed Trade Route Loading](./bugs/uncompressed-trade-route-loading.md) - [Patrol Letter Crash](./bugs/patrol-letter-crash.md) + - [Tavern Mission Lock Leak](./bugs/tavern-mission-lock-leak.md) - [Patches](./patches.md) - [High Res](./patches/high-res.md) - [Increase Alderman "Found Settlement" Mission Limit](./patches/increase-alderman-found-settlement-limit.md) diff --git a/src/auto-traders.md b/src/auto-traders.md index 5539985..0ce0c38 100644 --- a/src/auto-traders.md +++ b/src/auto-traders.md @@ -1,5 +1,30 @@ # Auto Traders -Both captains and administrators are represented by the same struct. +Captains, administrators and pirate captains are represented by the same struct. The array +lives behind the ships container at `0x006DD7A0` (array pointer at `+0x0`, count +word at `+0xF2` = `0x006DD892`, stride `0x10`). Each town chains its auto traders +through `field_0_next_auto_trader_index`, headed by the town's +`field_82E_auto_trader_chain_head` (see [Towns](./towns.md)). The chain mixes two +record kinds, discriminated by `field_8` (check `0x004FE150`, true for +`field_8 > 0x20`). The record initializer (`0x004FDF50`) fills `field_8` with a +random byte and reduces it `% 11` for captains (0..10), so the range encodes the +kind: captains always pass `<= 0x20`, the pirate captains - one record per town, +maintained by the spawn task - fail it. (The pirates are the tavern characters a +ship can be handed to; identified by matching the records' name ids against the +pirate captains in-game.) For pirates `field_8` doubles as the greed byte: the +loot share a tavern pirate demands is `25 + 5 * ceil(field_8 / 32)`, i.e. +35%..65% - verified against seven live pirates. The initializer also rolls +`field_2`/`field_3` as first/last +name ids (modulo the name-registry counts `0x006DDB70`/`0x006DDB74`), splits a +600-point budget randomly across the three skills, and stamps `field_4` from the +current date serial. The two kinds also differ in their wage formula: captain +records derive it from the trade skill alone (`0x004FE160`), pirate records from +the sum of all three skills plus a base. A town's tavern offers a captain +for hire while the chain contains a captain record no merchant employs +(`field_F_merchant_index` = `0xFF`): the captain resolver `0x005269A0`(town, +merchant) walks the chain applying exactly that, preferring a captain the asking +merchant already employs; the sibling resolver `0x005261D0` does the same for +the pirate captains. Verified against a live save: exactly the towns whose +taverns showed captains had a matching chain record. The following fields have been identified: ```c struct auto_trader @@ -17,6 +42,58 @@ struct auto_trader }; ``` +## Array Layout +Observed live (128-record array): indices 0..47 hold one world-generation pair +per town - the founding captain of an AI merchant's starting ship (elite skills, +exceeding the 600-point budget of the normal roller; verified by matching the +records against the AI ships' `field_42_captain_index` and owners) and the +town's pirate. The dynamic range +above holds spawned tavern captains and employed records; the tail is free +capacity, `memset` to `0xFF` and freelist-linked through `field_0` (the +allocator at `0x005097C0` grows the array by 64 records). Dismissing an office +administrator frees his record back to the freelist; re-employing allocates a +fresh one (new name and skills, wage 10, trade skill 0) - whatever the office +remembers about a previous administrator is stored on the office, not in this +array. + +A record's situation is encoded by chain membership, verified across live save +states: chained to a town = sitting in that town's tavern; unchained = serving +on a ship (`field_42_captain_index`). The record's merchant byte is only +maintained for the player - AI merchants' hires keep `0xFF` - so ship ownership +comes from the ship's `field_0_merchant_index` (AI merchants hold low indices +with two starting ships each; pirate ships and empty ship slots hold `0xFF`). +Records never expire; the population circulates between taverns and decks. + +## Captain and Pirate Spawning +A periodic task (`0x004E2634`, rescheduling itself in date-serial ticks, ~345 +per day) maintains both populations. Per town it records two flags: whether the +captain resolver finds an unemployed captain (called **with the merchant count +as the asking merchant** - a value no real merchant has, so only records with +`field_F_merchant_index` = `0xFF` count), and whether the pirate resolver finds +a pirate. + +- **Pirates**: when fewer than 3 towns have a pirate, one is spawned into a + pirate-less town (`0x00526A50(town, 1)` - the pirate initializer path). +- **Captains**: at 8 or more unemployed captains the task just reschedules far + out (`+0x700` ticks, ~5 days). Below that it compares the count against a + demand target derived from fleet statistics (`0x00509930` on the ships + container) and spawns a captain into a captain-less town when the count is at + or below the target, or below 2 (`0x00526A50(town, 0)`), rescheduling `+0x200` + ticks (~1.5 days) after a spawn and `+0x400` otherwise. + +No expiry logic exists in the task, and `field_4` is never compared against the +current date: an unhired captain stays until somebody hires him - including AI +ships, which fill their `field_42_captain_index` through the same resolver and +unlink the captain from the town (`0x0051A1B9`). A captain "disappearing" from a +tavern is somebody else's hire, not a timeout. + +Dismissing a captain re-links him into the town's chain still carrying the +dismissing merchant's index; it only flips to `0xFF` (generally hireable) when +that town's tavern is next opened (observed in-game). Until then the spawn task +does not count him - so dismissing captains without revisiting their taverns +makes the game under-count and spawn extras, pushing the world above the usual +two hireable captains (four observed live). + ## Buying Discount Auto traders buy cheaper as their trade skill grows. The captain (`0x004D5347`) and administrator (`0x004FF7E8`) buying routines both compute the percentage of the diff --git a/src/bugs/patrol-letter-crash.md b/src/bugs/patrol-letter-crash.md index 75392ee..0d8055c 100644 --- a/src/bugs/patrol-letter-crash.md +++ b/src/bugs/patrol-letter-crash.md @@ -17,9 +17,15 @@ the render DLL's text draw, which dereferences it without any guard The [letter script](../letters/scripted-letters.md) creation command stores the low byte of a script variable as the town byte, unvalidated (`0x004ED4E4`), and -the patrol/escort letter templates pass a variable that is not a town index - -observed bytes include 40, 95, 228 and 255. Drawing such a row reads past the -bank into unrelated globals, and the outcome depends on the value it hits: +the patrol script asks it for a variable that does not exist: command 37 of +`patrouille.p2m` - the "Patrol destination" letter - names **variable 131** in a +script that declares 25 variables (see +[Mission Scripts](../letters/mission-scripts.md)). The handler indexes the +variable array with that byte regardless, reading 424 bytes past its end, so the +town byte is whatever heap data follows the array - observed bytes include 40, 95, +228 and 255. It is the only out-of-range letter town variable in any of the game's +94 script files. Drawing such a row reads past the name bank into unrelated +globals, and the outcome depends on the value it hits: - ids 40..~81 land in the adjacent full town-name table, producing a genuine but wrong town name (typically the first town, "Edinburgh"); diff --git a/src/bugs/tavern-mission-lock-leak.md b/src/bugs/tavern-mission-lock-leak.md new file mode 100644 index 0000000..704bfa5 --- /dev/null +++ b/src/bugs/tavern-mission-lock-leak.md @@ -0,0 +1,75 @@ +# Tavern Mission Lock Leak + +## Summary +Looking at a mission in a tavern's side room locks the offer to the viewing merchant, so +that nobody else can take it. Leaving the side room by switching to another tavern page +releases the lock; closing the tavern window outright - a right click - does not. The +offer stays locked until it is re-issued, and a locked offer is invisible to every other +merchant. + +Single player never notices, because the side room accepts an offer locked to the asking +merchant himself. In multiplayer the leak denies the mission to the other players, and one +player can leak a lock in every town by opening each side room and right-clicking out. + +## The Lock +A side room offer is a [tavern mission](../letters/71-tavern-missions.md) letter whose +scheduled task holds the mission's script variables; the variable named by the letter's +`descriptor+0xC` is the lock, holding a merchant index while locked and `0xFFFFFFFF` (or +`0xFFFF`) while free. + +The lock is taken and released by the +[tavern interaction](../operations/0052-tavern-interaction.md) operation. Two of its types +matter here, and they work in completely different ways: + +- **Type 9, the side room** (handler `0x0053C7A3`) carries the task index and the variable + slot in the operation itself, at `+0x4` and `+0x6`. With a valid merchant it writes that + merchant into the variable if it is still free (`0x0053C808`); with an invalid merchant + index it writes `0xFFFFFFFF` back (`0x0053C7F0`). The panel sends the valid-merchant form + when a page is opened and the invalid-merchant form when it is left, so the lock is taken + and released as the player navigates. +- **Type 10, "Leave"** (handler `0x0053C619`) has no task index to work from and instead + walks the merchant's letter chain with `0x004D7900` to find his offers in that town. This + is the path a closing tavern window relies on, and it is broken. + +## The Defect +Both the entry and the continuation of that search compare the **letter index** against +the **merchant count** at `0x006DE4AA`, where the letter pool size at `0x006DD736` is +meant: + +``` +0053C6ED and eax, 0xffff ; letter index that 0x004D7900 found +0053C6F4 mov cx, [0x006DE4AA] ; merchant count (should be [0x006DD736]) +0053C6FB cmp ecx, eax +0053C6FD jbe 0x0053C80A ; bail when index >= merchant count +``` + +``` +0053C783 call 0x004D7900 ; next offer in the chain +0053C78F mov cx, [0x006DE4AA] ; merchant count (should be [0x006DD736]) +0053C796 cmp ecx, eax +0053C798 ja 0x0053C706 ; loop only while index < merchant count +``` + +A game has a few dozen merchants and a letter pool of hundreds of entries (400 in the save +below), so any offer sitting past the first few dozen pool slots fails the test and the +release never runs. The rest of the handler - the lock bytes `town+0x83C`..`+0x83F` and the +tavern's captains and pirates through the auto-trader chain - is reached before this search +and works, which is why captains do not leak the same way. + +## Observed +One save, Reval's tavern, reading the lock variable of the "Fugitive" offer (letter 264, a +pool index far above the 37 merchants) before and after each operation: + +|Action|Operation|Lock after| +|-|-|-| +|entering the tavern|type 255, merchant 37 (invalid)|`0xFFFFFFFF` - 255 is past the jump table| +|opening the side room|type 9, merchant 36|**`0x24`** - locked| +|clicking another page|type 9, merchant 37 (invalid)|`0xFFFFFFFF` - released| +|entering that page|type 4, merchant 36|unchanged - type 4 has no handler| +|opening the side room again|type 9, merchant 36|**`0x24`** - locked| +|right-clicking the window closed|type 10, merchant 36|**`0x24`** - not released| + +## Fix +Not fixed. The two comparisons above would have to read the letter pool size at +`0x006DD736` instead of the merchant count at `0x006DE4AA` - a four-byte change to each +instruction's operand, leaving the rest of the handler alone. diff --git a/src/ch05-04-sailor-pools.md b/src/ch05-04-sailor-pools.md index 75a62e3..6297849 100644 --- a/src/ch05-04-sailor-pools.md +++ b/src/ch05-04-sailor-pools.md @@ -12,3 +12,20 @@ Dismissing a captain sets the sailor reputation to `0`. The merchant struct's `sailor_pools` array at offset `0xf0` contains an `u8` for every town (indexed by the town's index), which denotes the size of the sailor pool of the merchant in that town. + +## Sailors Available for Hire +The pool byte is not the number a merchant can hire. `0x004F6CA0` (thiscall, one +argument, the town index) computes that: + +``` +cap = [town + 0x2E4] - 1 +if cap < 1 { return 0 } +return min(merchant->sailor_pools[town_index], cap) +``` + +The tavern's sailors page calls it at `0x005D4CB1` for the player merchant +(`[0x006DFC14]`) and its own town index (`window + 0x1BFC`), then caps what it offers at +`50` - the immediate at `0x005D4CD6` that +[mod-tavern-show-all-sailors](./patches/tavern-show-all-sailors.md) raises to `100`. +The cap the getter itself applies, from the town's `+0x2E4`, is a separate limit and +is not affected by that patch. diff --git a/src/letters/71-tavern-missions.md b/src/letters/71-tavern-missions.md new file mode 100644 index 0000000..eac0757 --- /dev/null +++ b/src/letters/71-tavern-missions.md @@ -0,0 +1,120 @@ +# Tavern Missions (type `0x71`) + +The missions a tavern's side room offers - patrol, escort, courier, smuggler, trader, +pirate hunter, fugitive, treasure map - are not a structure of their own. Each offer is +a [scripted letter](./scripted-letters.md) of type `0x71` in the recipient's letter +chain, paired with a scheduled task that carries the mission's script variables. The +letters window does not list them. + +## Finding a Town's Offers +`0x004D7900` (thiscall(this = the message pool `0x006DD730`, start index, town)) is the +game's own search. It walks the chain from `start index` through each message's `+0x6` +and returns the first index where + +- the type (`+0x4`) is `0x71`, +- the town byte (`+0x5`) is the town asked for, +- and the descriptor's `+0x4` date is still in the future. + +It returns `0xFFFF` at the end of the chain. The tavern side room starts at the player's +own chain head - `0x005303C0(0x006DE4A0, player merchant)` `+0xA` - and continues each +search from the found letter's `+0x6` (`0x005A7223`, `0x005A72C7`). + +## The Descriptor +The letter's `+0x8` descriptor holds: + +|Offset|Meaning| +|-|-| +|`+0x0`|length of the letter text| +|`+0x4`|date the offer expires; `0x004D7900` requires it to be in the future| +|`+0x8`|index of the mission's scheduled task (u16)| +|`+0xC`|which of that task's script variables holds the merchant who took the offer| + +The letter's `+0xC` text buffer begins with the offer's NUL-terminated **title** - +"Patrol", "Escort", "Treasure map" - which is what the side room draws as its window +title (`0x005D7FF2`); the body follows after that terminator. Escort and fugitive +missions share one script, so the title is what separates the kinds, not a type byte. + +## The Mission Task +The task lives in the [scheduled task](../scheduled-tasks/0000.md) pool `0x006DD73C` and +carries opcode `0x1B`; the side room refuses an offer whose task does not +(`0x005A7279`). Running that task runs the mission's letter script: the opcode's handler +enters the interpreter at `0x004ECF64`, which reads the task as + +|Field|Meaning| +|-|-| +|`+0x8`|the script blob| +|`+0xC`|the script's variable array| +|`+0x10`|script id| +|`+0x12`|number of variables| +|`+0x14`|program counter| + +The blob starts with the command count (u16), the variable count (u16), then one dword +offset per command; a command's bytes are at `blob + offsets[pc]` and its first byte is +the command. So `offsets[0]` is always `4 + count * 4`. + +The task's own due date (`+0x0`) is computed at creation as today's date plus one of the +variables (`0x00511527`), which is the deadline of the accepted mission - a different +clock from the offer's expiry in the descriptor. + +## What an Offer Is Worth +A command dispatches through the index table at `0x004F3054` into the handler table at +`0x004F2E34`, about 135 handlers for commands `1..0xFB`; the arithmetic the mission data +is built from works on the variable array: + +|Command|Bytes|Meaning|Handler| +|-|-|-|-| +|`0A dst imm32`|6|`var[dst] = imm32`|`0x004EEEAA`| +|`0B a b dst`|4|`var[dst] = var[a] + var[b]`|`0x004EEED1`| +|`0C a b dst`|4|`var[dst] = var[a] - var[b]`|`0x004EEEF3`| +|`0D a b dst`|4|`var[dst] = var[a] * var[b]`|`0x004EEF15`| +|`0E a b dst`|4|`var[dst] = var[a] / var[b]`|`0x004EEF36`| +|`09 mod dst`|3|`var[dst] = pseudo-random % var[mod]`|`0x004EEDFD`| +|`F8 merchant amount`|3|pays `var[amount]` to that merchant|`0x004ED20D`| + +Every mission's figures can be read from its own [script file](./mission-scripts.md), +which is where this table comes from - `F8` is what proves a variable is money, and the +letter templates name the variables they print (`%c` a sum, `%t` a town, `%B` an amount +of cargo), which confirms each one independently: + +|Script|Offer|Cargo|Destination|Money| +|-|-|-|-|-| +|8 smuggler|`var0` = the tavern's town|`var3`, rolled as `rand%40 + 4`|`var5`, a random town re-rolled against `var0`, named only after acceptance|`var3 * 150`, worked out when it pays| +|9 trader|`var0`|`var3`, same roll|`var5`, stated in the offer|`var3 * 90`, worked out when it pays| +|11 pirate hunter|`var0`|-|-|`var5 = (rand%3 + 1) * 1500 + rand%10 * 100`| +|12 treasure map|`var4`|-|-|**costs** `var1 = rand%7 * 100 + 800`; the treasure is `var8`, filled in at the end| +|13 courier|`var0`|3 loads, as literal text|a chain of towns|`(days_to_spare * 220) + 50`, at the end only| +|15 escort, fugitive|`var1`|-|`var10`, from the route command `0x29`|`var13 = rand%20 * 100 + 3000`| +|16 patrol|`var0`|-|a chain of towns|`var4 = (rand%3 + 1) * 1700` per foiled ambush; the payout is `var9`| + +Scripts 11, 15 and 16 compute their sum before they send the offer, so a pending offer +already holds it. The two transport orders hold no sum: the rate is fixed in the script +and applied to the cargo at the moment it pays, so their worth has to be derived from the +cargo. The patrol's figure is a rate, not a fee, and its voyage pay is never stated. A +smuggler additionally risks losing the goods, the chance falling the more other cargo his +ship carries. + +Variables are reused as a script runs - a delivered order overwrites its answer slot with +the sum it paid - so these meanings only hold for an offer still on the table. + +## Who Holds an Offer +The variable named by `descriptor+0xC` holds `0xFFFFFFFF` while nobody has taken the +offer and a merchant index afterwards. The side room accepts the letter when that +variable is the asking merchant or is at or above the merchant count (`0x006DE4AA`), and +walks past it otherwise (`0x005A7291`-`0x005A72A3`). Nothing in the field distinguishes +human players from AI merchants. + +The index is a **tavern lock**, of the same kind the tavern's other persons use: merely +opening the side room and looking at an offer stores the viewing merchant's index in it, +without accepting anything, and the [tavern interaction](../operations/0052-tavern-interaction.md) +operation's "Leave" handler is what releases it (writing `0xFFFF`). Switching to another +tavern page releases it; closing the tavern window with a right click does not, and the +offer stays locked - see [Tavern Mission Lock Leak](../bugs/tavern-mission-lock-leak.md). + +The index is also not cleared when a mission ends. Observed on a smuggler offer taken and +then failed: the offer came back as a **new letter** pointing at the same task and the +same variable array, its dates refreshed (the descriptor's expiry and variable 1, which +mirrors it, moved about 16 days out; the task's own due date about 10), some of the +mission data re-rolled - and the owner variable still holding the merchant who failed it. +Since the side room accepts its own merchant's index, such a re-issued offer stays +visible to him. The variable is better read as "the merchant this offer is bound to" than +as "who currently holds it". diff --git a/src/letters/mission-scripts.md b/src/letters/mission-scripts.md new file mode 100644 index 0000000..e81f041 --- /dev/null +++ b/src/letters/mission-scripts.md @@ -0,0 +1,119 @@ +# Mission Scripts + +Missions are not compiled into the executable. Each one is a **file** - a blob of the +same [letter script](./scripted-letters.md) bytecode the interpreter at `0x004ECF64` +runs - shipped inside the game's archives, so a mission's whole behaviour can be read +statically. + +## Where They Live +`scripts/missions_eng.ini` (in `p2arch0_eng.cpr`) maps script ids to file names in its +`[Missions]` section, and the `[GroupX]` sections decide which ids a game mode loads: +`[EinzelSpiel]` (single player) takes `History Standard`, while `[Multiplay]` and +`[Hotseat]` add `Standard2 Multiplay`. The loader at `0x005116F0` reads +`[Missions] `, prefixes `missions_addon/` (`0x006C04AC`) and opens the file. + +The tavern's [side-room missions](./71-tavern-missions.md) are these ids: + +|Id|File|Mission| +|-|-|-| +|8|`Schmuggler.p2m`|smuggler| +|9|`TransportAuftrag.p2m`|trader (transport order)| +|11|`PiratVernichten.p2m`|pirate hunter| +|12|`SchatzKarte.p2m`|treasure map| +|13|`Reisender.p2m`|courier| +|15|`Eskorte.p2m`|escort and fugitive (one script, two letters)| +|16|`patrouille.p2m`|patrol| + +`p2arch0_eng.cpr` holds 93 such files under `missions_addon/`; `p2arch1_eng.cpr` is a +patch archive containing a single replacement, `missions_addon/WettbewerbRennen.p2m`. + +## File Layout +The file *is* the blob the task's `+0x8` points at: + +|Field|Meaning| +|-|-| +|`+0x0`|command count (u16)| +|`+0x2`|variable count (u16)| +|`+0x4`|one u32 offset per command, so `offsets[0] == 4 + count * 4`| +|after them|the command bytes, then the string pool| + +Because every command's start is in the offset table, command **lengths** are known +without knowing the commands - a good check when decoding operand widths. + +The create-letter command's trailing dword is an offset into the string pool, pointing at +a group of NUL-terminated strings: title, body, then one string per answer button +(`Race`, `Dear Sir or Madam...`, `Participate`, `Refuse`). + +## Reading the Commands +A command byte dispatches through the index table at `0x004F3054` (`opcode - 1`) into the +handler table at `0x004F2E34`; valid opcodes are `0x01..0x74` and `0xE4..0xFB`, and +`0x00`, `0xFD`, `0xFE`, `0xFF` are handled before the table at `0x004F2CCB`. Handlers +return through `0x004F2C93`, which advances the program counter, or `0x004F2C97`, which +does not - branches set it themselves. Time is the game clock at `game_world+0x14`, 256 +ticks to a day. + +|Command|Bytes|Meaning|Handler| +|-|-|-|-| +|`00 v`|2|sleep until the absolute time in `var[v]`; if that is more than 256 ticks past, end the script|`0x004F2D71`| +|`05 dst`|2|`var[dst] = now`|`0x004EEC39`| +|`06 src dst`|3|`var[dst] = now + var[src] * 256` - a date so many days out|`0x004EECE2`| +|`09 mod dst`|3|`var[dst] = pseudo-random % var[mod]`|`0x004EEDFD`| +|`0A dst imm32`|6|`var[dst] = imm32`|`0x004EEEAA`| +|`0B a b dst`|4|add|`0x004EEED1`| +|`0C a b dst`|4|subtract|`0x004EEEF3`| +|`0D a b dst`|4|multiply|`0x004EEF15`| +|`0E a b dst`|4|divide|`0x004EEF36`| +|`17..1C a b T F`|7|compare `var[a]` with `var[b]` and jump to command `T` if it holds, `F` if not: `17` `<`, `18` `<=`, `19` `==`, `1A` `!=`, `1B` `>=`, `1C` `>`|`0x004EF2DF`+| +|`1D T`|3|jump to command `T`|`0x004EF3DF`| +|`1E ship dst`|3|`var[dst]` = the town that ship is in (`ship+0x39`), or `-1` unless its status is below `0xF` and not 2 or 3|`0x004EF3F0`| +|`21 dst`|2|a random town index|`0x004EF4B6`| +|`23 a dst`|3|a random first-name index (bit 7 of the low byte set - a table flag); printed by `%V`|`0x004EF588`| +|`24 dst`|2|a random surname index; printed by `%N`|`0x004EF65F`| +|`28 ship dst`|3|`var[dst]` = the ship's owner (`ship+0x0`)|`0x004EF9DF`| +|`29 a b c d e`|6|route/town picker through `0x00533210`|`0x004EFBAA`| +|`2A town ship dst`|4|`var[dst]` = whether that ship is in that town|`0x004EFDDF`| +|`39 dst`|2|`var[dst]` = the number of merchants whose mailbox gate (`+0x8`) is zero, i.e. human players|`0x004F0F6A`| +|`3A ship flag`|3|`var[flag]` nonzero reserves the ship (`ship+0x137`, capped at `0xFA`), zero releases it|`0x004F0FCF`| +|`43 dst`|2|`var[dst]` = the town count (`game_world+0x10`)|`0x004F1592`| +|`56 dst`|2|`var[dst]` = `[0x006DE52E]`, the home town of the merchant at `[0x006DFC14]` - the Hanse council's seat, set at `0x0041B5D0`|`0x004F1F14`| +|`57 merchant town dst`|4|`var[dst]` = that merchant's best ship docked in that town, `-1` if none|`0x004F01AA`| +|`58 a dst`|3|indirect load, `var[dst] = var[var[a]]`|`0x004F1F2C`| +|`59 idx src`|3|indirect store, `var[var[idx]] = var[src]`|`0x004F1F47`| +|`61 merchant delta`|3|adds `var[delta]` to the merchant's saturating word at `+0x16` through `0x004F36E0`|`0x004F2512`| +|`F1 ship amount dst`|4|cargo: a positive `var[amount]` is loaded onto the ship (`0x00518640`, `var[dst]` = how much fitted), a negative one taken off (`0x005186D0`)|`0x004EE1CD`| +|`F7 ...`|12|[create letter](./scripted-letters.md)|`0x004ED4A0`| +|`F8 merchant amount`|3|**pay**: `merchant+0x0 += var[amount]`, booked to `+0x4B8` (income) or `+0x4BC` (expenses) by sign|`0x004ED20D`| +|`FD v T`|4|sleep `var[v]` ticks, resume at command `T`|`0x004F2CF8`| +|`FE v`|2|sleep `var[v]` days and restart at command 0|`0x004F2DB1`| +|`FF`|1|end the script|`0x004F2DEF`| + +`F8` is what makes a variable money: a mission's "reward" is whatever variable reaches +this command, and a negative amount is a charge - the treasure map's asking price and the +race's entry stake are both taken this way. + +## The Letter Templates +Two conventions in the string pool make the templates worth reading before the bytecode. + +A `%` placeholder is followed by a code letter and then a **variable index**, so the text +names the variables it prints: `%t` a town, `%c` a sum of money, `%s` a ship, `%B` an +amount with the loads symbol, `%D` a date, `%N`/`%V`/`%R`/`%v` name parts, `%+` the +signature block. An index of `0` puts a NUL *inside* the template, so a parser has to +consume the argument byte rather than treat it as the end of the string. + +A `|` separates the question the offer asks from the reply the player gets once he +accepts. That is what makes a smuggler's destination a secret and a trader's public: both +scripts name the destination in their letter, but the smuggler only after the bar. + +## The Multiplayer Race Never Runs +`WettbewerbRennen.p2m` (id 18, multiplayer and hotseat only) is a complete Hanseatic +League race: a random wait of 200-400 days, an invitation letter with *Participate* and +*Refuse*, a 3,000 stake charged with `F8`, a start town and the town half the map away as +the finish, each entrant's best docked ship reserved with `3A`, an arrival poll, and a +prize of `participants * 3000 + 5000` for the winner. + +None of it can execute. Command 11 is `if human_players < 2 -> command 0 else -> command +153`, and command 153 is inside the closing release-and-restart loop rather than command +12 where the invitation begins - every other conditional in the file has its own +fall-through as one of the two targets, this one has neither. Walking the graph from the +entry point reaches 25 of the file's 166 commands; entering at command 12 instead reaches +all 166. Whatever the player count, the script only loops "wait, count players, restart". diff --git a/src/letters/scripted-letters.md b/src/letters/scripted-letters.md index 2045b2a..5cfd64a 100644 --- a/src/letters/scripted-letters.md +++ b/src/letters/scripted-letters.md @@ -5,9 +5,16 @@ commands with byte operands, executed against an array of script variables (interpreter object `+0xC`). One of its commands creates and sends a complete letter; its handler starts at `0x004ED4A0`. +The scripts themselves are files inside the archives rather than code - see +[Mission Scripts](./mission-scripts.md) for where they live, the file layout and the +commands decoded so far. + ## The Create-Letter Command -The handler allocates a 16-byte [message](../letters.md) and fills it from the -command's operands (`cmd[n]` below) and the script variables (`var[n]`): +The command is 12 bytes - `F7` then seven operand bytes and a dword - and the dword is an +offset into the script's own string pool, where the template is stored as the letter's +title, its body, and one string per answer button. The handler allocates a 16-byte +[message](../letters.md) and fills it from the command's operands (`cmd[n]` below) and +the script variables (`var[n]`): |Field|Value| |-|-| diff --git a/src/operations/0052-tavern-interaction.md b/src/operations/0052-tavern-interaction.md index 677b686..65625d7 100644 --- a/src/operations/0052-tavern-interaction.md +++ b/src/operations/0052-tavern-interaction.md @@ -5,13 +5,42 @@ The following fields have been identified: ```c struct operation_tavern_interaction { - int field_0_rand; - int field_4_merchant_index; - int field_8_town_index; - tavern_interaction field_C_interaction_type; + int field_0_opcode; + int field_4_rand; + int field_8_merchant_index; + int field_C_town_index; + tavern_interaction field_10_interaction_type; }; ``` +The handler is `0x0053C2C0`. It bounds the town index against the town count +(`0x006DE4B0`), then dispatches `interaction_type - 1` through the ten-entry jump table +at `0x0053C810`: + +|Type|Handler| +|-|-| +|1|`0x0053C3FA`| +|2|none| +|3|`0x0053C531`| +|4|none| +|5|`0x0053C434`| +|6|`0x0053C2FB`| +|7|`0x0053C472`| +|8|`0x0053C531`| +|9|`0x0053C7A3`| +|10|`0x0053C619`| + +Several of the handlers lock a tavern person to the interacting merchant: they store the +merchant index into a byte only while it still reads `0xFF` (nobody), which is what makes +the person unavailable to everyone else. Type 6 does this with `town + 0x83D`. + +The panel sends a page's own type with the real merchant index when the page is opened and +the same type with an **invalid** merchant index when it is left, which is how a lock is +taken and released; type 10 is sent when the window closes. Observed types: `9` for the +side room, `4` for the sailors page, and `255` on entering the tavern, which is past the +table and does nothing. `field_4_rand` is not always a random number - type 9 uses it as a +task index with a variable slot in its upper half. + Depending on the interaction type, one of the following actions may be done. ## 1 @@ -38,5 +67,47 @@ If all conditions are met, a criminal investigation scheduled task is scheduled The burglar is handled like the weapons dealer, except the exceptions for alderman, local mayor and town status don't exist. ## 9 +The side room, where the tavern's [mission offers](../letters/71-tavern-missions.md) are +taken. The handler (`0x0053C7A3`) reads a task index from `+0x4` and a variable slot from +`+0x6`, requires the task to carry opcode `0x1B` and the slot to be below the task's +`+0x12`, and then + +- with an **invalid** merchant index writes `0xFFFFFFFF` into that variable + (`0x0053C7F0`), releasing the offer, +- with a **valid** one writes the merchant index into it, but only while the variable is + still free (`0x0053C808`), locking the offer to him. + +A locked offer is skipped by every other merchant's side room, so the lock is what stops +two players taking one mission. + ## Leave +Type 10 (`0x0053C619`) releases every lock the interacting merchant holds in that town, +each guarded by "only if it is mine": + +- the four bytes `town + 0x83C` .. `town + 0x83F` go back to `0xFF`, +- the town's auto-trader chain (`town + 0x82E`) is walked and a record whose merchant + (`+0xF`) is this merchant is released - the tavern's captains and pirates, +- the merchant's tavern mission offers in that town are walked with `0x004D7900` and any + whose lock variable holds this merchant is set back to `0xFFFF` (`0x0053C772`) - see + [Tavern Missions](../letters/71-tavern-missions.md). The slot is bounded against the + task's `+0x12`. + +That last search never finds anything in practice: it compares the letter index it found +against the merchant count at `0x006DE4AA` instead of the letter pool size at +`0x006DD736`, at `0x0053C6F4` and again at `0x0053C78F`, so it gives up on any letter past +the first few dozen pool slots. Closing a tavern window therefore leaves side room offers +locked - see [Tavern Mission Lock Leak](../bugs/tavern-mission-lock-leak.md). + +Every release is guarded by a comparison against the operation's merchant index +(`op+0x8`), so it only releases locks held by that merchant - an operation carrying an +invalid merchant index releases nothing here. + +The panel builds this operation in three places, all in the tavern panel object +(`[0x006E54F8]`): `0x005A6604` sends type 10 with merchant `0xFFFFFFFF`, `0x005A7A7D` +sends the type from a register with the merchant count (also an invalid index), and +`0x005A7BA0` sends the type in `panel+0xB16` with the real player merchant. + +Observed in game: switching from the side room to another tavern page releases the mission +lock, while closing the tavern window with a right click leaves it held - see +[Tavern Mission Lock Leak](../bugs/tavern-mission-lock-leak.md). diff --git a/src/ships.md b/src/ships.md index 32784b3..0fa81a7 100644 --- a/src/ships.md +++ b/src/ships.md @@ -98,3 +98,42 @@ The following fields have been identified: 00000160 char field_160_ship_name[32]; 00000180 }; ``` + +## Iterating One Merchant's Ships + +`field_4_next_ship_of_merchant` chains every ship of one owner, and the head of that +chain is `+0xE` of the merchant record. The merchant array is at `game_world + 0x78` +with stride `0x650`; the accessor `0x005303C0` (thiscall on the game world, one +argument) computes `[this+0x78] + index * 0x650`. + +The game's per-merchant ship census at `0x004F0AB1` walks it: fetch the merchant +record, take `+0xE`, then follow `+0x4` while the index stays below the ship count at +`[0x006DD894]`. Iterating one player's fleet this way costs his ship count rather than +the world's - worth having when a late game holds a thousand ships. + +## Ship Status + +`field_134_status` takes values from `0` to at least `0x15`. Two of its classes are +established, and the game itself tests for exactly them side by side in the +per-merchant ship census at `0x004F0B02`/`0x004F0B11`/`0x004F0B26`: + +|Test|Meaning| +|-|-| +|`status <= 3`|the ship is at the town in `field_39_last_town`, not at sea| +|`status == 0xF`|merchant vessel at sea| + +Within the in-port family, `0` is a ship lying in the port and `3` is set while it +enters one - at `0x004E13FA`, which also clears the convoy fields `+0x6`/`+0x8` and ORs +`0x60` into the flags at `+0x3C`. `field_39_last_town` already names the town at that +point, and this is the state in which the town becomes enterable: a save with one ship +sailing to Rostock showed the ship flipping from `0xF` to `3` exactly when Rostock's +town view and tavern became reachable, still before docking. + +Nothing town-side marks that transition. A byte-exact snapshot of all 24 town structs +taken while the ship was at sea, diffed the moment Rostock became enterable, shows no +change at all in Rostock (the other towns differ only in economy fields) - so a town +carries no "enterable" flag and no list of the ships present. Ship status is the +whole answer. + +`0x12` is an AI pirate vessel at sea; `mod-scrollmap-render-all-ships` draws exactly +`0xF` and `0x12`. diff --git a/src/towns.md b/src/towns.md index 3c176da..ab27401 100644 --- a/src/towns.md +++ b/src/towns.md @@ -242,7 +242,8 @@ The following fields have been identified: 00000820 field_820 dd ? 00000824 field_824_current_ship_level db 4 dup(?) 00000828 field_828_always_zero db 4 dup(?) -0000082C field_82C dd ? +0000082C field_82C dw ? +0000082E field_82E_auto_trader_chain_head dw ? ; head of the town's auto-trader chain (records linked via their field_0, ended by an out-of-range index; 0xFFFF = empty, sentinel write 0x525F08). A hireable tavern captain is a chain record with field_8 <= 0x20 and merchant 0xFF - the captain resolver 0x5269A0(town, merchant) walks the chain applying exactly that, preferring a captain the asking merchant employs; the sibling resolver 0x5261D0 does the same for the town's pirate captain (field_8 > 0x20, one per town) 00000830 field_830 dd ? 00000834 field_834 db ? 00000835 field_835 db ? diff --git a/src/towns/tavern.md b/src/towns/tavern.md index db1b631..759d88a 100644 --- a/src/towns/tavern.md +++ b/src/towns/tavern.md @@ -1 +1,10 @@ # Tavern + +The side room's missions are not a structure of the tavern's own: each offer is a +type-`0x71` letter in the visiting merchant's letter chain, paired with a scheduled task +holding the mission's script variables. See +[Tavern Missions](../letters/71-tavern-missions.md). + +The tavern window itself is documented under [UI](../ui.md): vtable `0x00679B78`, its +object in the static `0x006E5574`, the selected page at `window + 0x1BF4` and the town at +`window + 0x1BFC`. The side room is page `9`, drawn by `0x005D7FD0`. diff --git a/src/ui.md b/src/ui.md index 3c3bbde..d384fca 100644 --- a/src/ui.md +++ b/src/ui.md @@ -11,15 +11,26 @@ them with the window manager. Many hold their object pointer in a static: |Static|Window| |-|-| |`0x006E5500`|town hall side menu| +|`0x006E5574`|tavern window| |`0x006E557C`|trading office window| |`0x006E558C`|town hall window| |`0x006E55C0`|shipyard window| |`0x006CBA74`|auto trade goods dialog ("Automatic maritime trading")| About twenty more statics in the `0x006E5500`-`0x006E55D0` cluster hold further -windows, each written exactly once by its constructor. Not every UI object has a -static: the scrollmap's trade route panel, for example, is only reachable through its -vtable (see [Trade Route Panel](./ui/trade-route-panel.md)). +windows. The store is not part of the constructor: the constructor takes `this` in ecx +and returns it in eax, and the mass-constructor's call site stores that into the +static - for the tavern window, constructor `0x005CB9B0` called at `0x00426C2C`, +`mov ds:0x006E5574, eax` at `0x00426C45`. A shutdown path around `0x00427F00` destructs +the objects and writes zero back into the statics (`0x00427F74` for the tavern). + +A window's own methods never read its static; the code that opens the window does. So +searching a window's address range for one of these statics finds nothing, and the way +to identify a static is to disassemble the mass-constructor around the call to the +window's constructor. + +Not every UI object has a static: the scrollmap's trade route panel, for example, is +only reachable through its vtable (see [Trade Route Panel](./ui/trade-route-panel.md)). ## Window Class Family The window classes share their vtable layout. Two slots are load-bearing for modding: @@ -42,6 +53,164 @@ already-open window registers it twice - it then draws twice and needs two close so programmatic refreshes must not re-run open (see [Trading Office Window](./ui/trading-office-window.md) for the working alternative). +## Window Titles +`0x00420C70` (stdcall, arguments: a string object and the window) draws a window's +title banner. It fetches graphic `0x791E` from the resource manager at `0x006DA820` +(`0x004B3DD0`), takes the four dwords of its rect and renders it together with the +text. Pages that show no title simply never call it, which leaves the strip at the +top of the window free - `mod-tavern-details` uses it for table rows. + +## Submitting Screen Areas +`0x004B9650` takes one argument by stdcall: a pointer to four dwords - left, top, +right, bottom. It returns without doing anything while `[0x006DCB94]` is non-zero, +or when the rect's width or height is zero. Otherwise it iterates the pointer array +at `0x006DCD20` (`[0x00670F6C]` entries), passing each entry to `0x004BB780` and the +rect to `0x004BB140` - both trampolines into `ddraw_Dll`. The function takes no +object of its own and is called from 585 places in the executable. + +The town hall window calls it for its own rect from its update method (vtable +`+0xF4`, `0x005E0850`), when the timestamp at `window + 0x1930` is older than the +date serial `0x006DE4B4` (compared at `0x005E08A6`); `mod-town-hall-details` zeroes +that timestamp so the call happens while its page is open. The trading office +window's update method (`0x005D9500`) contains no such call. + +Observed while adding a text page to the trading office window (see +`mod-trading-office-details`): with no such call for the window's rect, the area +shows a mix of old and new pixels until something else submits it - moving the mouse +across it, or alt-tabbing out and back. Making the call from inside the window's draw +method (`+0x9C`), once or on every frame, leaves the background art torn and the text +flickering. Making it from the window's update method renders the page cleanly. + +## Rich Text +Prose - letter bodies, the tavern's side room, anything that needs word wrap or inline +symbols - is drawn by a text-layout class of its own (vtable `0x0066E36C`, constructor +`0x004624D0`). Windows that need it own an instance: the tavern keeps one at +`window + 0x1608`. + +|Function|Signature| +|-|-| +|`0x00420A10`|thiscall(layout, string, x, y, width, height, color) - draws| +|`0x00462520`|thiscall(layout, string, width, 0) - lays out, called by the above| + +The layout pass fills a vector of 28-byte line records at `layout + 0x10`, with the count +at `+0x14`; the draw pass walks them. The string argument is a +[string object](#string-objects) passed by value as one dword: construct it with +`0x0064F2C1` and do **not** destroy it, because the draw destroys the parameter itself +(it calls `0x0064F253`). + +`width` is only the wrap limit. What `x` anchors depends on the line's alignment, which +the draw turns into an offset at `0x00420A97`-`0x00420AB7`: nothing for a left-aligned +line, `(width - line) / 2` for a centred one, and **minus the line's own width** for a +right-aligned one. So `x` is where the text starts under `\l` and where it ends under +`\r`. + +### Markup +The layout pass recognises exactly these escapes; anything else after a backslash is +literal text. + +|Escape|Meaning|Handled at| +|-|-|-| +|`\l` `\r` `\c`|align the line left, right or centre|`0x00462659`, `0x00462674`, `0x0046268B`| +|`\f`|select a font - letters open with `\f1_`|`0x004626A2`| +|`\t`|tab, taking an `_`-delimited argument|`0x00462764`| +|`\h`|substitution, `_`-delimited|`0x00462774`| +|`\C`|coin symbol, from `[0x006CC37C]`|`0x004627F5`| +|`\L`|cargo (load) symbol, from `[0x006CC384]`|`0x00462804`| +|`\B`|barrel symbol, from `[0x006CC380]`|`0x00462813`| +|`\d` + `A`..`Z`|the decorated initial capital for that letter, from the table in `[0x006CC3D4]`|`0x004626C5`| + +The symbols are graphics, not font glyphs: the escape takes the handle from `+0x4` of the +object in that global and measures it with `0x004BBB20` - the same call the icon blits use +(see [Graphics and Icons](#graphics-and-icons)) - so the layout can flow the text around +it. The `\d` family is the drop caps a +document starts with - all 26 letters exist. + +### The `\d` Off-By-One +`\dX` is three characters, but its branch advances the input pointer by two +(`add ebp,2` at `0x00462758`), where every other escape advances by its own length +(`inc ebp` for `\l` at `0x00462667`, `add ebp,2` for the two-character `\C` at +`0x004627A3`). The literal segment that follows therefore starts one byte past its end, +and the copy that flushes it computes its length as `end - start` without guarding +against a negative result: + +``` +00462CD5 sub ebx, eax ; length = end - start -> 0xFFFFFFFF +00462CD7 je done ; only exactly zero is handled +00462CD9 lea ecx, [ebx+1] ; malloc(0) +00462CE7 test ebx, ebx +00462CE9 jbe done ; unsigned, so -1 is not <= 0 +00462CEB ... ; copies 4GB into a 0-byte buffer +``` + +A `\dX` at the very end of a string therefore crashes the process with an access +violation in that copy. With any text after the escape the length stays positive and it +renders, at the cost of one following character being swallowed - so a `\dX` wants a +spare character behind it. + +## Graphics and Icons +The small icons the building pages put beside their numbers - a coin, a crate, the crew +figure - are graphics fetched by id from the resource manager at `0x006DA820` and blitted. +The sequence, as the tavern does it at `0x005CDD07`, the shipyard at `0x005F4ECE` and +[render_window_title](#window-titles) at `0x00420C8C`: + +|Step|Call|Notes| +|-|-|-| +|fetch|`0x004B3DD0` thiscall(manager, id)|returns the graphic record, or 0| +|check|record `+0x14` > 0 and `+0x4` != 0|the guards the game itself applies; `+0x14` is the frame count and `+0x4` the handle the renderer takes| +|measure|`0x004BBB20(handle, &size)`|writes width then height as two dwords - of the whole texture, see below| +|select|`0x004BB9C0(handle)`|the way `0x004BB8F0` selects a font| +|colour|`0x004BB870(0xFFFFFFFF)`|**required** - see below| +|blit|`0x004BB330(src_x, src_y, x, y, width, height)`|cdecl, six arguments| + +The blit **modulates the image by the constant colour**. Drawing an icon while a text +colour is still set produces a silhouette in that colour rather than the picture, so the +colour has to be set to `0xFFFFFFFF` first - and set back afterwards by whatever draws text +next. + +### Where the Ids Come From +The ids are not constants in the executable. Each window reads them by name through the +ini lookup `0x004BE0B0(section, key, file, ...)` on the store at `0x006DD524`, and caches +them in its own fields - the shipyard keeps its at `window + 0xC94` onward. The names live +in `scripts/parchment.ini` and `scripts/BuildingParchment.ini` inside `p2arch0_eng.cpr`, +one section per building: `[Werftparchment]` for the shipyard, `[Kneipeparchment]` for the +tavern, `[Kontorparchment]` for the trading office, and so on. + +Vanilla 1.1 values, from `[Werftparchment]`: + +|Key|Id|Icon| +|-|-|-| +|`WarenID`|16046|wares| +|`KohleID`|16047|money| +|`KonvoiID`|16043|convoy| +|`KapitaenID`|16053|captain| +|`BewaffnungKleinID`|16054|small armament| +|`BewaffnungGrossID`|16056|large armament| +|`HerzID`|20013|heart| +|`KnotenID`|20016|knots| +|`CrewID`|20017|crew| +|`TimeID`|32001|hourglass| + +`scripts/textures.ini` then maps an id to its picture: `[TEX20017]` is +`images/frames_listen/crew0002.tga` with `OffsetNSize0 = 0 0 26 18` - which is where the +blit's source offset and size arguments come from. + +### Sheets and Frames +One id can hold several pictures. The record's `+0x14` is the frame count and its `+0xC` +points at an array of four-dword rects - source x, source y, width, height - one per frame, +which are exactly the `OffsetNSize0`, `OffsetNSize1`, ... entries of the ini. Blitting a +frame means blitting its rect out of the shared texture, so the side menu's three +skill-bonus icons come from `[TEX20011]`, `images/sidemenu/bonus.tga`, `Count=3`, as three +16x16 frames at (0,0), (16,0) and (0,16). + +This is why measuring is the wrong way to size an icon: `0x004BBB20` reports the whole +texture, which for a sheet is every frame at once. The frame's own rect is the size to use, +and the icons are not one size anyway - 16x16 bonus frames, an 18x18 captain, a 26x18 coin, +crew figure and pirate - so anything placing text against an icon has to read its rect. + +Three of these icons are also reachable as [markup](#markup) escapes - `\C`, `\L` and +`\B` - which is the better route when the icon belongs *inside* a line of text, since the +layout measures it and flows the text around it. A blit is absolutely positioned. + ## Number Widgets The numeric row widgets (amounts, prices) cache their displayed value and text. The setter at `0x0045C930` (thiscall, one argument) clamps the value to the widget's