diff --git a/src/SUMMARY.md b/src/SUMMARY.md index 7591b7a..ebcbb16 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -8,16 +8,30 @@ - [Facilities](./basics/facilities.md) - [Time](./basics/time.md) - [Storage](./basics/storage.md) + - [Trading Office](./basics/office.md) - [Ship Types](./basics/ships.md) - [Ship Artillery](./basics/ship-artillery.md) - [Operations](./operations.md) + - [Hand Ship to Pirate](./operations/000d-hand-ship-to-pirate.md) + - [Auto Trader Skill Gain](./operations/0012-auto-trader-skill-gain.md) + - [Captain Retirement](./operations/0013-captain-retirement.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) - [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) + - [Administrator Skill Gain](./operations/0067-administrator-skill-gain.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) + - [Advance Time](./operations/00c4-advance-time.md) + - [Set Game Speed](./operations/00c8-set-game-speed.md) - [Scheduled Tasks](./scheduled-tasks/0000.md) + - [Ten-Day Update](./scheduled-tasks/0003-ten-day-update.md) - [Criminal Investigation](./scheduled-tasks/0005-criminal-investigation.md) - [Update Shipyard Experience](./scheduled-tasks/0006-update-shipyard-experience.md) - [Celebration](./scheduled-tasks/0007-celebration.md) @@ -59,7 +73,19 @@ - [Impact](./ships/sea-battles/projectiles/impact.md) - [Reefs](./ships/sea-battles/reefs.md) - [Auto Traders](./auto-traders.md) +- [Pirates](./pirates.md) +- [Graphics Library (SGL)](./graphics.md) +- [UI](./ui.md) + - [Trading Office Window](./ui/trading-office-window.md) + - [Auto Trade Goods Dialog](./ui/auto-trade-goods-dialog.md) + - [Ship Panel](./ui/ship-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) + - [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) @@ -75,6 +101,9 @@ - [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) + - [Tavern Mission Lock Leak](./bugs/tavern-mission-lock-leak.md) + - [Texture Cache Thrash](./bugs/texture-cache-thrash.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..04970c3 100644 --- a/src/auto-traders.md +++ b/src/auto-traders.md @@ -1,5 +1,28 @@ # 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. See [Wages](#wages) for `field_C_daily_wage`. 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 @@ -16,3 +39,249 @@ struct auto_trader unsigned __int8 field_F_merchant_index; }; ``` + +## 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 [game ticks](./basics/time.md), +256 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 = 7 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 (2 days) after a spawn and `+0x400` (4 days) 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). + +## Gaining and Losing Skill +Every one of the three skill bytes is written by a single operation, +[`0x12`](./operations/0012-auto-trader-skill-gain.md) (handler `0x00538A80`), and every +write goes through the same clamp: a gain is added to the current byte and the result is +cut to that skill's ceiling if it passes it or wraps. + +**The ceiling belongs to the record's slot, not to the man.** It is read from the table at +`0x00673B34` - 250, 200, 250, 150, i.e. displayed levels 5, 4, 5 and 3 - indexed by +`index & 3` for navigation, `(index >> 2) & 3` for trade and `(index >> 4) & 3` for +combat, where `index` is the record's position in the auto-trader array. + +A slot is settled when the record is **created**, and nothing moves a record afterwards, so +what matters is whether an action allocates a new record or reuses the existing one: + +- **Dismissing a captain does not destroy his record.** The finalizer (task `0x29`, + `0x004DDE70`) unlinks it, writes `0xFF` into its merchant byte and links it back into the + town's chain, so the same man - same slot, same ceilings, same skills - waits in that + tavern to be hired again. Hiring, dismissing, moving him between ships: none of it + reallocates anything. +- **An office administrator is the exception.** Operation `0x5E` frees his record to the + freelist when he is dismissed (`0x005098B0`, which also clears the office's autotrade + flags), and its hire path unconditionally allocates a fresh one and writes trade `= 0` + (`0x0053DA1D`) before recomputing the wage - so the record that comes back is a different + man who has to earn his trade skill again from nothing. Ceilings never enter into it: his + growth path has no ceiling table, and his navigation and combat are dead stats. + +For a human player's captains the navigation ceiling does double duty as the threshold the +other two skills are tested against, which is what decides whether trade and combat ever +reach ceilings of their own; see +[the ten-day update](./scheduled-tasks/0003-ten-day-update.md#what-that-means-for-a-captains-final-skills). + +**Skill is lost as well as gained.** The clamp runs for all three skills on every +application, even one whose gain for that skill is zero, so a skill sitting *above* its +ceiling is cut back by the first gain event that reaches the record. Fresh records are +rolled roughly uniformly over `0..255` per skill (sum capped at 600), so starting above a +ceiling of 150 or 200 is common: on a live save a captain carrying 253 in all three came +back as `250 / 150 / 250`, two and a half displayed levels of trade gone. + +### Where gains come from +|Producer|Gain| +|-|-| +|the [ten-day world update](./scheduled-tasks/0003-ten-day-update.md)|`rand % 51` for a human player's captain, a flat `8` for an AI merchant's, about four times a year per captain| +|a pirate raider reaching its hideout (`0x00514C93`)|a flat `50`, once per crewed ship in the arriving convoy, credited to the convoy's acting ship - see [Pirates](./pirates.md#bands-and-hideouts)| + +Nothing else writes a skill byte, and nothing anywhere decrements one. Nothing depends on +what the captain has been doing either, with the single exception of the hideout award: +gain is a roll, not a reward for sailing, trading or fighting. A record sitting in a +tavern gains nothing at all, because the sweep only walks merchants' ship chains - eleven +tavern pirates were byte-identical across two dumps 259 days apart. + +That also makes the hideout award by far the fastest growth in the game, and the only one +a player can drive. A ship handed to a pirate captain leaves the merchant's ship chain +(its `field_0_merchant_index` becomes `0xFF`), so the ten-day sweep never sees it again +and the award is its only source of skill: one measured raider went from `60 / 164 / 186` +to all three ceilings inside 162 days. + +### Administrators +An office administrator gains too, from the same ten-day sweep but on his own path - +[operation `0x67`](./operations/0067-administrator-skill-gain.md), which adds exactly 43 +points, one displayed level, and stops when that would wrap. A fresh administrator starts +at `0`, so his trade skill walks `0, 43, 86, 129, 172, 215` and no further; it is always +an exact multiple of 43. His navigation and combat are never touched, and only a **human** +merchant's administrators gain at all. + +An administrator is therefore much simpler than a captain: only trade does anything for him +(it is the [buying discount](#buying-discount), and he neither sails nor fights), there is +no ceiling table on his path, and there is nothing to lose to a clamp. Every administrator +who is left in place long enough ends at level 5 - the `42%` roll only decides how long that +takes, roughly two eligible rounds per level. + +### Retirement +`field_4` is a birth stamp in ticks - the initializer writes `game_time - offset` with +`offset = 46720 * (48..79) + 1792 * (0..31)`, so a new record is 24.0 to 40.1 years old. +The ten-day sweep retires a captain once his age passes `0x474A00` ticks, almost exactly +**50 years**, marking the record with `field_E` and scheduling task `0x27` to take him off +his ship. An AI merchant's captain goes at once; a human player's gets a probability roll +that cannot fire before about 51.6 years, and when it does it arrives as +[operation `0x13`](./operations/0013-captain-retirement.md) and a message. + +## Wages +`field_C_daily_wage` is recomputed from the skills whenever they change, by one of two +routines - which one depends on the path that touched the record, not on the kind of record +it is: + +|Routine|Formula|Used by| +|-|-|-| +|`0x004FE190`|`(nav + trade + combat) / 50 + (field_8 % 11) + 10`|the [skill gain](./operations/0012-auto-trader-skill-gain.md) handler, so every captain and pirate| +|`0x004FE160`|`20 * (trade / 43) + 10`, i.e. only ever 10, 30, 50, 70, 90 or 110|the administrator paths, operations `0x5E` and `0x67`| + +Both check out against live saves: a captain with `39/61/125` and `field_8` = 6 reads wage +22, a pirate with `237/251/112` and `field_8` = `0xFB` reads 31, and administrators at trade +0, 43, 86, 129 and 215 read 10, 30, 50, 70 and 110. + +**What the interface shows is not the record's wage.** For an administrator the trading +office window calls `0x00500F10(office)`, which returns the record's wage **plus** +`office+0x2D2`, and falls back to `office+0x2D2 + 10` when the post is vacant - the cost of +the level 0 administrator you would get. [`office+0x2D2`](./basics/office.md) counts the +**business buildings the merchant owns in that town**, one per building - verified across +several saves and offices. So a busy office pays its administrator a gold a day more for +every business it runs, and an office with none pays exactly what the record says. + +## 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 gain trade skill like captains do, in whole displayed levels +(see [Administrators](#administrators) above), even though the game never shows it - a +level 5 administrator quietly buys everything 10% cheaper. + +## Running a Route Stop +The executor is `0x004D5200` (thiscall on `0x006DD728`, arguments `(ship, office)`). It +runs from the **ships tick** (`0x00506720`), not from an operation: the tick tests bit 0 of +`ship+0x136`, and `0x00518860` resolves the office for the ship's merchant and current town +through the office lookup `0x005308A0` before calling the executor on the ship's current +[route stop](./file-formats/rou.md#applied-routes-at-runtime). + +The stop's instructions are executed in **two passes over the whole ware order array**, +never interleaved: + +|Pass|Handles| +|-|-| +|1|selling to the town (positive price) and unloading into the office (zero price, negative amount)| +|2|buying from the town (negative price) and loading from the office (zero price, positive amount)| + +Between them, `0x004D5600` calls the ship's recompute (`0x005182B0`, see +[Ships](./ships.md#crew-cutlasses-and-the-equipment-weight)) and keeps the returned **free +capacity** as a budget, clamped at zero. Pass 2 caps every purchase and every office load +against that budget and decrements it as it spends. + +Two consequences worth knowing: + +- **Unloading and selling always happen before loading and buying**, for every ware, no + matter how the order array is arranged - the ship frees space first and the capacity + budget is measured afterwards. Confirmed in-game. +- The order array only sequences wares **within** a pass. It still matters there: the + capacity budget and the merchant's cash are consumed in that sequence during pass 2, so + earlier entries get first claim on the hold when not everything fits. + +Each pass walks all 24 slots of the order array; an entry outside `0..0x17` is **skipped** +rather than ending the walk, and a ware whose amount is `0` has no instruction. Every +quantity is floored to a whole in-game unit (the barrel/bundle scaling table at +`0x00672C14`) before anything moves. The amount field is a cap, not a target: + +|Instruction|Quantity| +|-|-| +|unload into the office|`-amount`, capped by what is aboard| +|load from the office|`amount`, capped by [`0x00500EC0`](./basics/office.md#what-the-lock-bit-does) and by the capacity budget| +|sell to the town|`0x0052EA80(ware, town, price) - town stock`, capped by `amount`| +|buy from the town|capped by `amount`, by the capacity budget, and by the merchant's cash| + +A purchase the merchant cannot fully afford is **scaled down proportionally** +(`0x004D5705`) rather than skipped, and its cost runs through the captain's +[buying discount](#buying-discount). Goods entering the office go through +[`0x004FF6F0`](./basics/office.md#average-purchase-price), which is how the office's +average purchase price follows the cargo. + +Finally, the stop's [action byte](./file-formats/rou.md#action-byte) is consulted twice: +bit `0x04` (the first stop of the route) builds a record from the captain's name ids and +the ship's registry id and passes it to `0x004D6530`, and bit `0x02` clears the low bits of +`ship+0x136` once the stop is finished. + +## The Administrator's Trading +An office administrator is not driven by the ships tick but by the world tick itself: + +``` +advance_time 0x00530E80 + └─ 0x0051BA10 walk the town's offices, chaining office+0x2CA + └─ 0x004FFF20 the per-office periodic routine, dispatching on office+0x2D6: + bit 0x10 -> 0x004FFA30 AI-merchant offices (skipped when merchant+0x8 is 0) + bit 0x02 -> 0x004FFC20 + bit 0x01 -> 0x004FF780 the administrator's trading +``` + +The town whose offices are visited comes from the tick counter (`tick >> 3`), so offices +are worked through in a staggered round rather than all at once. + +`0x004FF780` walks the wares from 23 down to 0 and acts on each +[order](./basics/office.md) whose price is non-zero. On the sell side (positive price, the +minimum price) it offers the stock **above** the minimum store quantity - so that column is +a floor the administrator sells down to, not a target it tops up to. Purchases apply the +administrator's own [buying discount](#buying-discount). diff --git a/src/basics/office.md b/src/basics/office.md new file mode 100644 index 0000000..1f4e7ef --- /dev/null +++ b/src/basics/office.md @@ -0,0 +1,56 @@ +# Trading Office +A trading office starts with a complete [storage](./storage.md) struct - the same layout +towns use, so an office's stock of a ware is `office + 0x4 + ware*4` - and continues with +office-specific fields after it. The whole record is `0x44C` bytes. + +|Offset|Type|Meaning| +|-|-|-| +|`0x000`|storage|the office's own [storage](./storage.md), stock at `+0x4 + ware*4`| +|`0x2C4`|u16|owning merchant index| +|`0x2C6`|u8|town index| +|`0x2C8`|u16|next office of the same merchant| +|`0x2CA`|u16|next office in the same town| +|`0x2D2`|u16|business buildings the merchant owns in this town - one per building, verified across several saves and offices. Counted in a loop over the town's buildings (`0x004FFDD9`, `0x004FFE5A`) and added to the administrator's [wage](../auto-traders.md#wages) wherever the interface shows it| +|`0x2D6`|u16|state flags; bit `0x1` means the office holds administrator orders| +|`0x2F2`|u16|the administrator, as an index into the [auto trader](../auto-traders.md) array; out of range when the office has none| +|`0x2F4`|i32[24]|administrator order price per ware, the sign encoding the direction| +|`0x354`|i32[24]|administrator minimum store quantity per ware, raw units| +|`0x3B4`|u32|"lock min. store quantity" bitmap, one bit per ware| +|`0x3B8`|f32[24]|average purchase price of the stock, per ware| + +The order arrays are written by +[operation `0x5B`](../operations/005b-office-autotrade-setting-change.md) and the lock +bitmap by [operation `0x66`](../operations/0066-office-autotrade-lock-change.md). There is +no direction field: a positive price is a sell order's minimum price, a negative one a buy +order's maximum price negated, and `0` means no order. What the administrator then does +with them is in [Auto Traders](../auto-traders.md#the-administrators-trading). + +## Average Purchase Price +`0x004FF6F0` (thiscall on the office, arguments `(ware, quantity, price)`) is how goods +enter an office's stock with a price attached. It adds `quantity` raw units to the ware's +stock and folds `price` into the running average at `office + 0x3B8 + ware*4`: + +``` +average = (average * old_stock + quantity * price) / (old_stock + quantity) +``` + +A slot that was empty takes the incoming price directly instead. This average is the +purchase price the trading office UI shows for the stock on hand, and it is on the same +per-unit basis as a ship's `field_B4` average prices - which is exactly what the caller +passes when a ship unloads cargo into the office. + +## What the Lock Bit Does +`0x00500EC0` (thiscall on the office, argument `(ware)`) answers "how much of this ware +may an auto trader take out of here?": + +``` +administrator index out of range -> current stock +lock bit for this ware clear -> current stock +minimum store quantity <= 0 -> current stock +otherwise -> max(current stock - minimum store quantity, 0) +``` + +So the "Lock min. store quantity for auto trade ships" checkbox does exactly what its +name says, and only in that one direction: it fences the administrator's minimum store +quantity off from ships **loading** at the office. It does not restrain the +administrator's own trading, and it does not affect ships unloading into the office. diff --git a/src/basics/ship-artillery.md b/src/basics/ship-artillery.md index 12e6f8a..39b7ab8 100644 --- a/src/basics/ship-artillery.md +++ b/src/basics/ship-artillery.md @@ -27,6 +27,28 @@ ShipWeaponId::Bombard => 2000 ShipWeaponId::Cannon => 1000 ``` +## Combat Power +Next to the scaling table sits a second, byte-wide table at `0x00672CC8` giving each +weapon a combat power: + +```rust +ShipWeaponId::SmallCatapult => 9 +ShipWeaponId::SmallBallista => 10 +ShipWeaponId::LargeCatapult => 22 +ShipWeaponId::LargeBallista => 24 +ShipWeaponId::Bombard => 30 +ShipWeaponId::Cannon => 18 +``` + +Fitting a weapon (`0x0051A4E0`) adds its scaling factor from `0x00672CB4` to the ship's +`field_11C` - the capacity the guns occupy - and its power from `0x00672CC8` to the +ship's `field_120`, so `field_120` is the ship's total artillery power. Removing a +weapon subtracts both. That total is one of the two halves of the fighting strength the +pirate AI compares before attacking; the other is the crew count in `field_40`. + +Two further six-byte tables sit in the same block and are not yet identified: +`0x00672CC0` = 32, 32, 77, 77, 96, 58 and `0x00672CD0` = 60, 80, 60, 80, 90, 90. + ## Ship Artillery Slots A ship's artillery slots are filled with the following enum: ```c diff --git a/src/basics/time.md b/src/basics/time.md index 14e18fd..e00c1d0 100644 --- a/src/basics/time.md +++ b/src/basics/time.md @@ -1,10 +1,33 @@ # Time The game time is stored in the static `game_world` struct at offset `0x14` as *ticks*, and is increased by the `advance_time` function at `0x00530E80`. +That function has exactly one caller: the inline handler of the +[Advance Time operation](../operations/00c4-advance-time.md) - game time only ever +advances through the operation queue. ## Ticks Every ingame day is 256 ticks long, so there are 93440 ticks in a year. Consequently the least significant byte conveniently encodes the time of day. +## The Calendar +The day, month and year are not counted up as time passes - they are **derived from the +tick counter once a day** by `0x005310D0`, so nothing else has to keep them in step: + +|Field|Meaning| +|-|-| +|`game_world+0x0`|day of the month, 1-based| +|`game_world+0x1`|month, 0-based| +|`game_world+0x2`|year (u16), `ticks / 93440`| +|`game_world+0x4`|day of the year (u16), `(ticks >> 8) % 365`, so 0-based| + +The routine writes the year and the day of the year straight from the counter, then walks +the month table at `0x00672D78`/`0x00672D7A` - the cumulative day of the year at which each +month starts and ends - to find the month, and subtracts that month's start to get the day +of the month. + +The day of the year is read by game logic, not just by the interface: the +[ten-day update](../scheduled-tasks/0003-ten-day-update.md) resets its round counter on any +run that lands in the first ten days of a year. + ## Ticking Objects Different game objects tick at different intervals. Information about what happens in those ticks can be found in the respective chapters. @@ -38,3 +61,44 @@ This results in the following town tick behaviour: #### Facilities All facilities tick when their town ticks. + +## Game Speed +The tick pacer inside `execute_operations` (`0x00546640`) converts real +milliseconds into [Advance Time operations](../operations/00c4-advance-time.md) +once per frame. How many ticks a batch gets depends on the pacing *mode* +(`operations+0x92C`) and its ms-per-tick divisor: + +|Mode|What|ms per tick|Max ticks per batch| +|-|-|-|-| +|0|normal play|`operations+0x8D4` - set by the speed slider|8| +|1|fast forward (the mode with its own window)|`operations+0x8D8` (2 in vanilla)|256 (a day)| +|2|local map (town view, sea battle)|the constant `[0x00673CF8]` = 3375|1| + +The six positions of the speed slider set the mode-0 divisor to 3515, 468, 351, +234, 117 and 78 ms per tick (measured in vanilla 1.1); the slider never changes the +mode. Entering the local map switches to mode 2, whose pace is a hard constant - +which is why the speed controls have no effect there. All speed changes travel as +the [Set Game Speed operation](../operations/00c8-set-game-speed.md), and +`operations+0x914` is the master run flag the pacer requires (0 = paused). + +The same pacer also enqueues the autosave operation (`0xC2`) whenever the timer at +`operations+0x940` expires (period `operations+0x944`, 180000 ms in vanilla). + +## The Frame Clock +Real time reaches the game through one updater (`0x004BD180`), called once per +frame from the main loop's frame function: it reads the OS time, sleeps the +remainder of a 20 ms frame (the game is capped at 50 fps, `0x004BD1A9`), and +computes the frame's elapsed milliseconds into two globals: + +|Global|Meaning| +|-|-| +|`0x006DCCF0`|raw OS time of the last frame| +|`0x006DCCF4`|this frame's elapsed ms| +|`0x006DCCF8`|accumulated game clock (ms), the sum of all frame deltas| + +The tick pacer measures against `0x006DCCF8`, and so does the local-map simulation +(ship movement, projectiles, battle AI): it is paced neither by game ticks nor by +how often its update runs - calling the update several times per frame moves +nothing - but purely by this clock. Scaling the delta before it is stored (its +computation at `0x004BD1E2` is a detourable 5-byte sequence) therefore speeds up +the local map and the world alike; mod-ui-tweaks' "extra speed" does exactly that. diff --git a/src/bugs/patrol-letter-crash.md b/src/bugs/patrol-letter-crash.md new file mode 100644 index 0000000..0d8055c --- /dev/null +++ b/src/bugs/patrol-letter-crash.md @@ -0,0 +1,46 @@ +# 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 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"); +- 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/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/bugs/texture-cache-thrash.md b/src/bugs/texture-cache-thrash.md new file mode 100644 index 0000000..2ba09e3 --- /dev/null +++ b/src/bugs/texture-cache-thrash.md @@ -0,0 +1,66 @@ +# Texture Cache Thrash + +## Summary +Opening a building window collapses the framerate: the game drops from a locked +48 fps to around 17-20 fps and recovers the instant the window is closed. It is +worst in the shipyard, clearly noticeable in the bath house, and absent in the +tavern and town hall. The drop happens in the unmodded game, at any resolution, +and even on a page that draws nothing at all - it does not depend on what the +window shows. + +While the window is open the process spends roughly **800 ms of every second** +inside `AIM.dll`, decoding images. The files being decoded are not just the +window's own art: the coastal cliffs, the harbour water, ship sprites, town +walkers and the building's background photo all re-decode from the archives +dozens of times per second, every frame, for as long as the window is open. + +## Details +Sprites are drawn from decoded source images, which the graphics library keeps in +an LRU cache with a hard byte budget - see +[the Decoded Image Cache](../graphics.md#the-decoded-image-cache) for the +addresses. The budget's built-in default is **16 MiB** +(`ddraw_Dll+0x5F734` = `0x01000000`). + +A town view alone fits comfortably. A town view *plus* an open building window +does not: measured with a live counter on `ddraw_Dll+0x80F14`, the working set +settles at about **19.4 MB**. That is only ~3 MB over the ceiling, but the +consequence is total: the eviction loop drops the least recently used images to +get back under budget, and those are precisely the images the next frame draws +again. Every frame evicts what the next frame needs, so the hit rate collapses to +zero and the whole visible scene is re-decoded continuously. + +This also explains the symptoms that look inconsistent: + +- **The shipyard is the worst** because its interior has the largest animated + overlays (a `362 x 4620` water strip, seagulls, a pulley, workers), so it pushes + the working set furthest past the ceiling. +- **The drop deepens over several seconds** rather than appearing at once, as the + cache walks its way into the fully thrashing state. +- **The next building window seems slower too** if opened immediately afterwards, + and normal again after a few seconds in the town view - the cache refilling. +- **The tavern and town hall are fine**: their overlays are small enough that the + total stays under the budget. + +Raising the budget removes the effect completely: the counter settles at the +19.4 MB the scene actually needs, decoding drops from ~800 ms/s to ~10 ms/s, and +the framerate returns to 48 fps within a second. + +The library has a configuration key for exactly this - `TextureCacheSize` in +`gl.cfg` - and GOG even ships it set to `48000000`, which would have been ample. +It has never done anything: the library's own +[gl.cfg parser is dead code](../graphics.md#glcfg), never called, so the game +always runs on the 16 MiB default. The v1.1b beta patch does not help either - +it replaces only the executable, which knows nothing about the cache. + +## Fix +[mod-fix-texture-cache-thrash](https://github.com/P3Modding/p3-lib/tree/master/mod-fix-texture-cache-thrash) +waits for `ddraw_Dll.dll` to be loaded and writes 48 MiB into the budget at +`ddraw_Dll+0x5F734` - about 2.5x the measured working set, and the same ballpark +as the `48000000` GOG tried to configure. The counter only ever grows to what is +actually in use, so real memory use rises by a few dozen MB at most. The write +happens only if the `0x01000000` default is found, leaving other builds of the +library untouched. + +A much larger budget is not better: a display mode switch - alt+tab, or opening +the menu at its own resolution - releases and rebuilds the cached surfaces, so an +oversized cache makes those switches slower. diff --git a/src/ch05-00-merchants.md b/src/ch05-00-merchants.md index 9c3a83f..7588793 100644 --- a/src/ch05-00-merchants.md +++ b/src/ch05-00-merchants.md @@ -4,7 +4,7 @@ 00000000 { // XREF: merchant_wrapper/r 00000000 int field_0_money __tabform(NODUPS); 00000004 int field_4; -00000008 __int16 field_8; +00000008 __int16 field_8_control_word; 0000000A __int16 field_A; 0000000C unsigned __int16 field_C_first_office_index; 0000000E unsigned __int16 field_E_first_ship_id; @@ -197,4 +197,16 @@ 00000648 int field_648; 0000064C int field_64C; 00000650 }; -``` \ No newline at end of file +``` + +`field_8_control_word` is **`0` for a human player and non-zero for an AI merchant** (game +setup writes `0x8001` at `0x00544644`, `0x005446E5` and `0x005491D3`). Several systems +branch on it: bit `0x4` marks the background merchants [pirates](./pirates.md) leave alone, +it decides which growth path the ten-day sweep gives a merchant's +[captains](./auto-traders.md#gaining-and-losing-skill), and it gates whether that sweep +touches his administrators at all. + +`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/ch05-01-ranks.md b/src/ch05-01-ranks.md index afa2828..28c30d3 100644 --- a/src/ch05-01-ranks.md +++ b/src/ch05-01-ranks.md @@ -1,32 +1,43 @@ -# Ranks -Merchants may rank up in their hometown on the first day of every month. -The office "Personal" page gives rough hints whether more wealth or more reputation is needed to get to the next level, until the following wealth and reputation requirements are met: - -|Minimum Reputation|Rank| -|-|-| -|5|Trader| -|7.5|Merchant| -|10|Travelling Merchant| -|15|Councillor| -|25|Patrician| - -|Minimum Company Value|Rank| -|-|-| -|100,000|Trader| -|200,000|Merchant| -|300,000|Travelling Merchant| -|500,000|Councillor| -|900,000|Patrician| - -However, to actually reach the next rank, the following reputation values must be reached: - -|Minimum Reputation|Rank| -|-|-| -|7|Trader| -|12|Merchant| -|20|Travelling Merchant| -|40|Councillor| -|60|Patrician| - -## Building Permits -Once you reach the Trader rank in a town, it'll grant you the building permit. +# Ranks +Merchants may rank up in their hometown on the first day of every month. +The office "Personal" page gives rough hints whether more wealth or more reputation is needed to get to the next level, until the following wealth and reputation requirements are met: + +|Minimum Reputation|Rank| +|-|-| +|5|Trader| +|7.5|Merchant| +|10|Travelling Merchant| +|15|Councillor| +|25|Patrician| + +|Minimum Company Value|Rank| +|-|-| +|100,000|Trader| +|200,000|Merchant| +|300,000|Travelling Merchant| +|500,000|Councillor| +|900,000|Patrician| + +However, to actually reach the next rank, the following reputation values must be reached: + +|Minimum Reputation|Rank| +|-|-| +|7|Trader| +|12|Merchant| +|20|Travelling Merchant| +|40|Councillor| +|60|Patrician| + +## Building Permits +Once you reach the Trader rank in a town, it'll grant you the building permit. + +## Where the Rank is Stored +A merchant's rank is kept **per town**, as a byte at `merchant + 0x39C + town_index`, and +computed by the code in front of `update_merchant_reputation_and_value` (`0x004F7653`, +`0x004F7699`, `0x004F78B1`, `0x004F79B6`, `0x004F7A27`, `0x004F7A99`, `0x004F7AE2`, +`0x004F7B0E`, `0x004F7B31`) from the per-town reputation float at +`merchant + 0x2FC + town_index*4` and the company value at `merchant + 0x46C` - the +`0xDBBA0` = 900,000 comparison at `0x004F7AD4` is the Patrician step of the table above. +Observed values in a live 24-town game run 3..5 for the AI merchants, and the +[pirate AI](./pirates.md) reads the home-town entry as its "is this merchant worth +robbing" test. 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/file-formats/aim.md b/src/file-formats/aim.md index caa04d5..cdfc99a 100644 --- a/src/file-formats/aim.md +++ b/src/file-formats/aim.md @@ -25,4 +25,27 @@ render. `.aim` files can be converted to and from PNG with the community `aim_converter` tool (`to-png` / `to-aim`). +## The Codec +`AIM.dll` exports the codec as 32 decorated C++ symbols - `AIM_INIT`, +`AIM_CONVERT_FILE`, `AIM_CONVERT_MEMFILE`, `AIM_CONVERT_RAW`, `AIM_FREE`, +`AIM_WRITE_IMAGE` and so on - operating on an `AIM_IMAGE` struct whose first +fields are the pixel pointer, a second buffer pointer, width and height. Only +`ddraw_Dll.dll` and `Vto.dll` import it; the executable never calls it directly, +so every decode in the game is one the +[graphics library](../graphics.md#the-decoded-image-cache) asked for. + +Inside the DLL, every file decode passes through one call site at `AIM+0x2984`, +a cdecl function taking `(image, file_path, file_data, file_size)`. The path +argument makes that site the place to observe or substitute images by name - +[mod-high-res](../patches/high-res.md) hooks it to swap in larger background +art, and it is what identifies the files behind the +[texture cache thrash](../bugs/texture-cache-thrash.md). + +Decoding is pure CPU work and not cheap: measured against the game's own +`AIM.dll`, chunk-`34` strips decode at roughly 180-250 Mpx/s on a modern +machine, so the shipyard's `362 x 4620` water strip costs about 9 ms and the +trading office's `7740 x 363` overlay about 11 ms per decode. JPEG assets +(`innenbild_werft01.jpg` and friends) go through `ijl11.dll`, the Intel JPEG +Library, at comparable cost. + [To be completed] \ No newline at end of file diff --git a/src/file-formats/rou.md b/src/file-formats/rou.md index b3e11cd..7a3d0e5 100644 --- a/src/file-formats/rou.md +++ b/src/file-formats/rou.md @@ -34,3 +34,41 @@ 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). + +The ware order array is a **sequence of ware indices**, not a set of flags: it is the order +in which the stop's instructions are carried out. Entries outside `0..0x17` are skipped +rather than ending the sequence, and a ware whose amount is `0` carries no instruction. The +order only sequences wares within each of the two passes the executor makes over it - see +[Running a Route Stop](../auto-traders.md#running-a-route-stop) for what actually happens +at a stop. + +## 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/graphics.md b/src/graphics.md new file mode 100644 index 0000000..3883291 --- /dev/null +++ b/src/graphics.md @@ -0,0 +1,162 @@ +# Graphics Library (SGL) + +All rendering goes through `ddraw_Dll.dll`, Ascaron's own graphics library - its +debug path names the project: `D:\coding\SGL_DDRAW\Release\ddraw_dll.pdb`. It +exports 198 functions, all prefixed `sgl_`, and does its own software drawing on +top of DirectDraw surfaces. + +It must not be confused with `ddraw.dll` in the same folder: that file exports +`DirectDrawCreate`, `DDInternalLock`, `D3DParseUnknownCommand` and the rest of the +Microsoft DirectDraw API and is GOG's replacement for the system component. It +knows nothing about the game. + +## How the Executable Binds It + +The executable does not import `ddraw_Dll.dll` statically. It carries a table of +180 entries at `0x006BBC2C`, one per function it wants, each `0x44` bytes: + +|Offset|Size|Meaning| +|-|-|-| +|`+0x00`|`0x40`|the export name, NUL terminated (`sgl_DrawBitmapRect`, ...)| +|`+0x40`|4|pointer to the slot that receives the resolved address| + +The stored pointer is four bytes below the slot the code actually calls through, +so the slot for an entry is `[entry+0x40] + 4`. Every bound function also gets a +16-byte thunk of the form `jmp DWORD PTR [slot]` in the `0x004BAEC0`-`0x004BBB40` +block, and that thunk address is what the rest of the executable calls. This is +how the drawing helpers used elsewhere in this book resolve: + +|Thunk|Export| +|-|-| +|`0x004BB140`|`sgl_AddClipRect_r`| +|`0x004BB330`|`sgl_DrawBitmapRect`| +|`0x004BB340`|`sgl_DrawBitmapRectWithAlphaMask`| +|`0x004BB430`|`sgl_FillSolidRect`| +|`0x004BB4A0`|`sgl_FreeTexture`| +|`0x004BB490`|`sgl_FreeMemoryTexture`| +|`0x004BB620`|`sgl_LoadTexture`| +|`0x004BB640`|`sgl_LoadTextureImage`| +|`0x004BB650`|`sgl_FreeTextureImage`| +|`0x004BB780`|`sgl_SetActiveClipper`| +|`0x004BB870`|`sgl_SetConstantColor`| +|`0x004BB8F0`|`sgl_SetFont`| +|`0x004BB9B0`|`sgl_SetRenderDest`| +|`0x004BB9C0`|`sgl_SetRenderSource`| +|`0x004BBA10`|`sgl_SetTextMode`| +|`0x004BBB20`|`sgl_GetTextureInfo`| + +Reading the table is the reliable way to name any of the ~180 drawing calls in +the executable, and the same walk recovers the slot a mod can hook to intercept +one (see [Graphics and Icons](./ui.md#graphics-and-icons) for the blit sequence +these are used in). + +## The Decoded Image Cache + +Sprites are drawn from decoded source images, and those images are kept in an +LRU cache inside the library. Everything about it is internal - none of it is +visible through the `sgl_*` exports, which is why hooking the library's free +functions never shows a cache eviction. + +|What|Where| +|-|-| +|Bytes currently held|`ddraw_Dll+0x80F14`| +|Getter / setter|`ddraw_Dll+0x1C1D0` / `+0x1C1E0`, thiscall on the object at `ddraw_Dll+0x6E960`| +|Budget|`ddraw_Dll+0x5F734`, default `0x01000000` (16 MiB)| +|Eviction loops|`ddraw_Dll+0x1C2EF` and `+0x1C39A`| +|Load (decode) path|`ddraw_Dll+0x13350`, reached through the gate at `+0x130A0`| + +Both eviction loops read the counter, compare it against the budget, and while +it is greater release the least recently used entry through its vtable `+0x20`. +The release path ends at `ddraw_Dll+0x1309B`: it zeroes the image's `+0x18` and +`+0x1C`, frees the pixel allocation at `+0x44`, and subtracts that allocation's +size from the counter - `WORD [img+0x22] * DWORD [img+0x28]`, the same product +the load path adds, which is why the counter is a byte total and the default +budget is exactly 16 MiB. + +The gate in front of the loader decides per draw whether a decode is needed: + +- if the texture has an image (`[tex+0x40]`) and that image still has its pixels + (`[img+0x18]`, the field the release path zeroes), the draw proceeds from the + cache; +- otherwise, if the state byte `[tex+0x3D]` is 5, the image is decoded from the + memory file at `[tex+0x2C]` with the flags at `[tex+0x34]` or-ed with + `0x30000`. + +The decode itself is handed to `AIM.dll` (see [AIM Images](./file-formats/aim.md)), +which `ddraw_Dll.dll` imports: `AIM_CONVERT_MEMFILE` at IAT `+0x50028` and +`AIM_CONVERT_RAW` at `+0x50034` do the work, `AIM_INIT` and `AIM_FREE` bracket +them. Only `ddraw_Dll.dll` and `Vto.dll` import `AIM.dll`; the executable never +calls it directly. + +Because the budget is a hard ceiling with least-recently-used eviction, a working +set slightly larger than the budget degenerates into a complete miss rate. That +is a real, shipped bug - see +[Texture Cache Thrash](./bugs/texture-cache-thrash.md). + +## gl.cfg + +`gl.cfg` in the game folder is the library's configuration file - and exactly one +key in it still works. + +**The live key is `DLL`, and the executable reads it.** During startup +(`0x004BC010`, called from `0x0046359E` before `game.ini` is processed) the game +opens `gl.cfg`, reads it line by line, splits each line on `" \t=\n"` and +compares the first token against `DLL`, case insensitively. The second token of +a matching line becomes the name of the render DLL to load; with no such line the +default `"ddraw_dll.dll"` (`[0x006BEEC4]`) is used. No section header is +required - the reader scans every line. This is the supported way to point the +game at a different or wrapped renderer. + +**Every key belonging to the library itself is dead.** +`P3HardwareSettings.exe` writes the file with a `BEGIN D3D` section and the keys +`NoMMX`, `NoISSE` and `NoHardwareScroll`, and `ddraw_Dll.dll` contains a parser +for those plus `VideoMemorySize`, `TextureCacheSize`, `ScreenWidth` and +`ScreenHeight` (key dispatch from `ddraw_Dll+0x11E65`, value parser `+0x11810`, +section scanner `+0x12190`, filename string `+0x5FA40`). None of it runs: the +section scanner has no callers anywhere in the library, and nothing references +the filename string, so the library never opens the file and none of its own +keys reach the running game. + +That distinction matters in practice. GOG's shipped `gl.cfg` sets +`TextureCacheSize = 48000000`, which would have been large enough to avoid +[the cache thrash](./bugs/texture-cache-thrash.md) entirely - and it has never +had any effect. The v1.1b beta patch does not change this either: it ships only +a `Patrician3.exe` and a CRT, no replacement library, and its executable carries +the same literals (`SGL`, `gl.cfg`, `DLL`, `PatternFile`, `Adapter`, `Size`, +`ColDepth`) with no cache keys at all. + +## Scene Sprites + +The town and sea maps draw from a global array of graphic definitions at +`[0x006E2E70]`, with its entry count at `[0x006E2E6C]`. Definitions are indexed +by a byte, and each one holds: + +|Offset|Meaning| +|-|-| +|`+0x14`|frame count| +|`+0x18`|frame table, 12 bytes per frame (`+4` width, `+6` height, `+8`/`+0xA` draw offsets)| +|`+0x34`|the texture handle passed to `sgl_SetRenderSource`| + +A parallel byte table at `[0x006E2E74]` holds per-definition flags; bit `0x40` +gates whether the definition draws at all. + +The scene window ([the local map scene](./ui.md#the-local-map-scene), +`[0x006E51AC]`) owns the objects that use them: its tile map is at `+0xC310` with +the row stride at `+0xC314`, holding `WORD` ids that index the object table at +`+0x3FC`, and the current scroll offset sits at `+0xC650`/`+0xC654`. A sprite +object carries + +|Offset|Meaning| +|-|-| +|`+0x0E` / `+0x12`|world x / y| +|`+0x14`|animation phase| +|`+0x15`|frame selector| +|`+0x2C`|graphic definition index| +|`+0x30`|alpha, `0xFF` for opaque| + +and draws through its vtable: for the most common tile class (vtable +`0x00676398`) the draw slots `+0x8` and `+0xC` are `0x00579B80` and `0x00579EC0`. +Both select the definition's texture with `sgl_SetRenderSource`, apply the alpha +through `sgl_SetConstantColor` when `+0x30` is not `0xFF`, and blit with +`sgl_DrawBitmapRect`. Linked graphics are dispatched to the object table through +`0x005599D0`, whose tail jumps to the target object's own vtable `+0x8`. 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/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 new file mode 100644 index 0000000..5cfd64a --- /dev/null +++ b/src/letters/scripted-letters.md @@ -0,0 +1,54 @@ +# 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 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 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| +|-|-| +|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 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 - 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 +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.md b/src/operations.md index e54c08f..ecdd02e 100644 --- a/src/operations.md +++ b/src/operations.md @@ -18,6 +18,7 @@ The function `schedule_operation` at `0x00543F10` inserts an operation at the ne ## Execution The function `execute_operations` at `0x00546870` removes up to 53 operations from `operations` and executes them. +Opcodes below `0xC1` go through the operation switch at `0x00535760` (called from the single site `0x00546934`); opcodes `0xC1..0xD4` never reach the switch - `execute_operations` handles them inline through its own jump table at `0x00547290`. The high family covers session control: `0xC2` autosave, [`0xC4` advance time](./operations/00c4-advance-time.md), [`0xC8` set game speed](./operations/00c8-set-game-speed.md). ## Debugging The following IDC script adds scripted breakpoints to the executing and scheduling functions, allowing the investigation of P3's operation behavior: @@ -93,5 +94,6 @@ The following operations have been identified: |0x52|Tavern Interaction| |0xc2|Autosave| |0xc4|Advance Time| +|0xc8|Set Game Speed| |0x9f|Start Ship Combat| |0x96|Steer Manually| diff --git a/src/operations/000d-hand-ship-to-pirate.md b/src/operations/000d-hand-ship-to-pirate.md new file mode 100644 index 0000000..2a84e9d --- /dev/null +++ b/src/operations/000d-hand-ship-to-pirate.md @@ -0,0 +1,20 @@ +# Hand Ship to Pirate +Operation `0x0D` marks one of the player's ships as sailing for a +[pirate captain](../pirates.md), or clears that mark again. + +|Offset|Type|Value| +|-|-|-| +|0x00|u32|opcode `0x0D`| +|0x04|u32|ship index| +|0x08|u32|non-zero to raise the black flag, `0` to clear it| + +The handler at `0x005386C0` (operation switch case `0x005358D5`) takes one of two paths. +For a ship not in a convoy it requires status `0x0F` - a merchant vessel at sea - and then +writes only three fields: `field_15C_is_pirate = 1`, `field_3D |= 0x18` and +`field_136 = 0`. Clearing instead writes `field_15C_is_pirate = 0` and `field_3D |= 0x10`. +For a ship in a convoy it ORs `0x1000` into the convoy's `+0x18` and walks the convoy's +ships. + +Note what it does **not** do: the ship keeps its owner and its status. Both change later, +when the ship actually puts to sea as a raider - see +[Bands and Hideouts](../pirates.md#bands-and-hideouts). diff --git a/src/operations/0012-auto-trader-skill-gain.md b/src/operations/0012-auto-trader-skill-gain.md new file mode 100644 index 0000000..1f0c615 --- /dev/null +++ b/src/operations/0012-auto-trader-skill-gain.md @@ -0,0 +1,80 @@ +# Auto Trader Skill Gain +Operation `0x12` raises the skills of the [auto trader](../auto-traders.md) commanding a +ship. It is the only writer of the three skill bytes in the game. + +|Offset|Type|Value| +|-|-|-| +|0x00|u32|opcode `0x12`| +|0x04|u32|ship index - the record is that ship's `field_42_captain_index`| +|0x08|u32|navigation gain| +|0x0c|u32|trade and combat gain| + +The handler at `0x00538A80` (operation switch case `0x00535971`) bails unless the ship +index is below the ship count and the ship carries a captain index below the auto-trader +count. For each of the three skills it adds that skill's gain to the current byte and +clamps the result against the skill's ceiling: + +```python +new = (skill + gain) & 0xFF # a byte addition +if new > cap or new < skill: # over the ceiling, or wrapped + skill = cap +else: + skill = new +``` + +It finishes by recomputing the wage from the new skills (`0x004FE190`). + +## Trade and combat share one gain field +`0x00538B16` and `0x00538B2E` are the same three instruction bytes: the handler reads +`+0x0C` for the trade gain and reads it again for the combat gain. The record's fourth +payload dword at `+0x10` is never read here and never written by either producer. + +So **trade and combat always move by the same amount.** The difference between them is +fixed when the record is created and nothing but a clamp can change it: whichever of the +two is higher reaches its ceiling first and waits there while the other catches up. The +same applies in reverse - a gain aimed at trade raises combat even when combat is already +past the threshold that would have refused a combat roll, which is what lets the pair +climb well beyond that threshold. See +[the ten-day update](../scheduled-tasks/0003-ten-day-update.md#what-that-means-for-a-captains-final-skills) +for what that does to a captain's final skills. + +Measured on a live save: `trade - combat` was unchanged for every captain across two dumps +259 days apart, except where one of the two had met its ceiling in between. + +## The ceiling is a property of the record's slot +The ceilings come from the four-byte table at `0x00673B34` - **250, 200, 250, 150**, i.e. +displayed levels 5, 4, 5 and 3 - indexed by bits of the record's **index in the +auto-trader array**: + +|Skill|Table index| +|-|-| +|navigation|`index & 3`| +|trade|`(index >> 2) & 3`| +|combat|`(index >> 4) & 3`| + +So a captain's ceiling in each discipline belongs to the array slot, not to the man, and +because records are recycled through the freelist a re-hire inherits whatever the +allocator hands out. + +## A skill above its ceiling is pulled down +The clamp is unconditional and runs for all three skills on every application - including +one whose gain for that skill is `0`. An over-ceiling skill is therefore cut to its +ceiling by the first operation `0x12` that reaches the record, whichever skill the gain +was meant for. + +That is not a corner case. The record initializer `0x004FDF50` produces each skill by +reinterpreting the bits of a float (`0x004FE046`, `0x004FE073`, `0x004FE0AE`), giving a +roughly uniform `0..255` per skill with the sum capped at 600, so a fresh record above +150 or 200 is common. Measured on a live save: a captain with all three skills at 253 was +cut to `250 / 150 / 250` the first time the scan reached him, losing 103 raw points - two +and a half displayed levels - of trade. + +## Producers +Two, and only two: + +- the [ten-day world update](../scheduled-tasks/0003-ten-day-update.md), which is what + grows captains over time; +- a pirate raider reaching its hideout, which awards a flat `50` and `50` + (`0x00514C93`, see [Pirates](../pirates.md#bands-and-hideouts)). + +Nothing else writes a skill byte, and nothing anywhere decrements one. diff --git a/src/operations/0013-captain-retirement.md b/src/operations/0013-captain-retirement.md new file mode 100644 index 0000000..dc9b14f --- /dev/null +++ b/src/operations/0013-captain-retirement.md @@ -0,0 +1,25 @@ +# Captain Retirement +Operation `0x13` retires the [auto trader](../auto-traders.md) commanding a ship once he +is old enough, and tells the player about it. + +|Offset|Type|Value| +|-|-|-| +|0x00|u32|opcode `0x13`| +|0x04|u32|ship index| +|0x08|u32|auto-trader index| + +The handler at `0x00538C40` (operation switch case `0x00535980`) validates both indices, +then: + +1. schedules task `0x27` (`0x004DDC00`) for the next tick, carrying the ship index at + `data+0x0` and the trader index at `data+0x4` - that task is what actually takes the + captain off the ship; +2. sets `field_E` on the auto-trader record, which stops the + [ten-day update](../scheduled-tasks/0003-ten-day-update.md) queueing a second removal; +3. for a ship whose owner is a human player (`merchant+0x8` = 0), composes a type `0x52` + message from the captain's name ids and the ship's registry id and posts it through + `0x004D6530`. + +The operation is only ever enqueued by the ten-day update, and only for a human player's +ships - an AI merchant's captains are retired by that task directly, without an operation +and without a message. 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 +``` 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/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..5f3c336 --- /dev/null +++ b/src/operations/0066-office-autotrade-lock-change.md @@ -0,0 +1,24 @@ +# 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). + +What the bit does once set is +[`0x00500EC0`](../basics/office.md#what-the-lock-bit-does): it caps how much of the ware an +auto trader may load out of the office at `stock - minimum store quantity`. diff --git a/src/operations/0067-administrator-skill-gain.md b/src/operations/0067-administrator-skill-gain.md new file mode 100644 index 0000000..a961cac --- /dev/null +++ b/src/operations/0067-administrator-skill-gain.md @@ -0,0 +1,28 @@ +# Administrator Skill Gain +Operation `0x67` raises an office administrator's trading skill by one displayed level. + +|Offset|Type|Value| +|-|-|-| +|0x00|u32|opcode `0x67`| +|0x04|u32|auto-trader index| +|0x08|u32|merchant index| +|0x0c|u32|town index| +|0x10|u32|the new trade skill byte| + +The handler at `0x0053DDF0` (operation switch case `0x005364B6`) resolves the office +through `0x005308A0(merchant, town)`, checks that `office+0x2F2` still names the trader in +the payload, writes the byte to the record's trade skill, recomputes the wage +(`0x004FE160`) and posts a type `0x7C` event through `0x004D6530`. + +Unlike a captain's [skill gain](./0012-auto-trader-skill-gain.md) there is no ceiling +table here. The only limit is in the producer, the +[ten-day world update](../scheduled-tasks/0003-ten-day-update.md), which computes +`new = (trade + 43) & 0xFF` and drops the whole thing when that wraps below the current +value. Since a freshly hired administrator starts at `0`, an administrator's trade skill +walks exactly `0, 43, 86, 129, 172, 215` and then stops - always an exact multiple of 43, +which is one displayed level. Navigation and combat are never touched. + +Because there is no ceiling and nothing to lose, the outcome is not in doubt: every +administrator left in place long enough reaches level 5. Only the pace is random - the +producer rolls `(rand & 0x3FF) < 0x1B3` on each of his eligible rounds, so about 42% of +them pay. 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..882c34b --- /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 [ship panel](../ui/ship-panel.md)'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/operations/00c4-advance-time.md b/src/operations/00c4-advance-time.md new file mode 100644 index 0000000..b44ba38 --- /dev/null +++ b/src/operations/00c4-advance-time.md @@ -0,0 +1,22 @@ +# Advance Time +Operation `0xC4` advances the game time - the ONLY way it advances: its handler +makes the executable's single call to `advance_time` (`0x00530E80`, see +[Time](../basics/time.md)). + +|Offset|Type|Value| +|-|-|-| +|0x00|u32|opcode `0xC4`| +|0x04|u32|from: the current game tick| +|0x08|u32|to: the tick to advance to| + +Unlike the low operations, `0xC4` never reaches the operation switch: opcodes +`0xC1..0xD4` are handled inline by `execute_operations` through its own jump table +at `0x00547290`, and `0xC4`'s inline handler at `0x00546A1C` calls +`advance_time(game_world, from, to)` directly. + +The operations are produced by the tick pacer inside `execute_operations` +(`0x00546640`): once per frame it converts the real milliseconds elapsed since the +last advance into a tick count according to the current +[game speed](../basics/time.md#game-speed), and enqueues one `0xC4` for the batch. +The same pacer enqueues the autosave operation (`0xC2`) whenever the autosave timer +(`operations+0x940`, period `operations+0x944` - 180000 ms in vanilla) expires. diff --git a/src/operations/00c8-set-game-speed.md b/src/operations/00c8-set-game-speed.md new file mode 100644 index 0000000..f5a2620 --- /dev/null +++ b/src/operations/00c8-set-game-speed.md @@ -0,0 +1,28 @@ +# Set Game Speed +Operation `0xC8` changes the [game speed](../basics/time.md#game-speed) - every +speed control ends up enqueueing it. + +|Offset|Type|Value| +|-|-|-| +|0x00|u32|opcode `0xC8`| +|0x04|u32|ms per tick of mode 0 (`operations+0x8D4`) - the speed slider's divisor| +|0x08|u32|ms per tick of mode 1 (`operations+0x8D8`)| +|0x0C|u32|the pacing mode itself (`operations+0x92C`)| +|0x10|u32|stored to `operations+0x91C`, purpose unknown| + +A field of `-1` leaves that setting unchanged. Like +[Advance Time](./00c4-advance-time.md) this opcode is handled inline by +`execute_operations` (handler `0x00546DCF`, jump table `0x00547290`), not by the +operation switch. + +The handler also sets the master run flag `operations+0x914` to 1 - unless a +network round is pending (`operations+0x928` nonzero), in which case it pauses +instead (run flag 0, mode 0). The mode-0 divisor passes through extra clamp logic +(`0x00546DFA`..`0x00546E36`, a floor derived from the global `[0x0066DE7C]`, not +fully decoded) before landing in `+0x8D4`; a change of mode resets the pacer's +last-advance timestamp (`operations+0x938`). + +Known enqueuers: the fast-forward button (`0x00420300`, mode 1) and its +counterpart (`0x004202A0`, mode 0), both leaving the divisors at `-1`; the speed +slider, which passes a real mode-0 divisor; and assorted game flows that force +mode 0 (e.g. `0x00433865`). diff --git a/src/pirates.md b/src/pirates.md new file mode 100644 index 0000000..c386e8e --- /dev/null +++ b/src/pirates.md @@ -0,0 +1,237 @@ +# Pirates +Pirates are not scripted. The `.p2m` interpreter that runs letter and mission scripts +(`0x004ECF64`) has exactly one caller, the letters task, so no bytecode ever touches a +ship. Everything below is native code: a state machine inside the per-tick ships update, +plus a handful of scheduled tasks that maintain the pirate population. + +There are two different things called "pirate": + +- a **pirate ship** roaming the map, owned by nobody (`field_0_merchant_index` = `0xFF`) + and flying status `0x12`. These belong to *bands* based at hideouts, and are what this + chapter is about. Both fields are written together at `0x005151A4`, where a ship puts to + sea as a raider: status `0x12`, `field_15C_is_pirate`, merchant index `0xFF`, and a + convoy record of its own from `0x005062F0`. `0x00516225` does the same alongside a full + refit. +- a **pirate captain**, one of the tavern characters an + [auto trader record](./auto-traders.md) can be, whom the player can put in command of + one of his own ships. Handing the ship over (operation `0x0D`) only sets + `field_15C_is_pirate`, and only on a ship already at sea under its normal status `0x0F`; + it leaves the merchant index alone. But a raider actually sailing for its pirate captain + reads `field_0_merchant_index` = `0xFF`, like any other pirate ship - measured in a live + save, where two ships just given to pirates read `0xFF` while their sister ships under + ordinary captains still read the player's index. What keeps such a ship tied to its owner + is `field_15D`, not the merchant index. + +## Bands and Hideouts +The ships container holds up to five **band** objects, pointers at `0x006DD7AC` +(container `+0x0C`). World generation (`0x0054A480`) creates `2 * n + 1` of them, where `n` +is the **Pirates activity** setting: the byte at `[[0x006CC3E8] + 0x13]`, holding **0 for +low, 1 for normal and 2 for high** - the Game settings dropdowns are 1-based on screen and +stored one lower (`0x0049935B` copies it from `[window+0x1B90]`). That gives one, three or +five bands, and five is why the game reserves exactly five band slots. "Difficulty" is +only a preset over the individual settings: `0x00463B20` writes one value into all of +them. Every other reader of this byte is pirate code, so it governs nothing else. +Each is a 24-byte heap object (`new` at `0x0064F7B9`, constructor `0x00513720`, seeded by +`0x00513D80`): + +|Offset|Meaning| +|-|-| +|`+0x0`, `+0x4`|lazily created sub-objects (`0x00514530` allocates a 0x6C-byte one)| +|`+0xA`|the convoy index the band's raiding party uses| +|`+0xE`|hideout index| +|`+0xF`|a class byte, `1` when the hideout's own class is 3..5, otherwise a random 0/2/3| +|`+0x12`|head of the band's ship chain (ships linked through `field_6_next_ship_index_in_convoy`)| +|`+0x14`|behaviour class, `rand & 3`| +|`+0x15`|state; `1` makes `0x00514000` return the ships, free the object and null the slot| + +Every band is ticked once per in-game day - the ships tick calls `0x00514000` on each +slot when the tick's low byte is `0x6F` (see [Time](./basics/time.md): a day is 256 +ticks). Several scheduled tasks also work on these objects: opcodes `0x1F`, `0x23`, +`0x24`, `0x25`, `0x30`, `0x32` and `0x34`. + +Hideouts come from a runtime table at `0x006DDBB0`, 52 bytes per record, at least 48 of +them readable: + +|Offset|Meaning| +|-|-| +|`+0x0`|x (read at `0x00505FD5` and `0x0051516D`)| +|`+0x4`|y| +|`+0x8`|pointer into a 0x50-stride array| +|`+0x10`|region id, 0..3| +|`+0x11`|class byte 0..7, which drives the band's `+0xF`| +|`+0x12`|`0x2D` followed by `l`, `r` or `c` - ASCII, so part of a name| +|`+0x14`..|four (x, y) dword pairs close to the hideout| + +A hideout is not a place on the map: a ship that reaches its hideout's coordinates is +taken off the map entirely (`0x0050D040`, then `0x00514B80` hands it to the band) and +parks at position `32767, 32767`. While parked it is **repaired at exactly 1000 hull per +day** - measured over 61 days on one ship and 41 days on another, which was built from +nothing at the same rate - and it is also refitted: artillery totals climb back to the +hull's full fit, while crew losses are made good more slowly. `0x00514D40` dispatches a +ship again only when its captain is a pirate record and its health is back at maximum +(`0x00514DE0`). + +`0x00514B80`, the hand-over itself, walks the arriving convoy and for each ship unlinks it +from the convoy, links it into the band's chain at `+0x12`, parks it off the map, and: + +- **cashes the cargo into the band.** Every ware is zeroed and its amount scaled by the + per-ware factors at `0x00673A18`; the total divided by 1000 is added to the band's + `+0x16`. +- **awards the captain.** For every ship that carries one, it enqueues + [operation `0x12`](./operations/0012-auto-trader-skill-gain.md) with a gain of `50` for + navigation and `50` for the other skills (`0x00514C93`), crediting the **acting** ship of + the convoy (`convoy+0x10`) rather than the ship being processed - so a multi-ship raiding + party pays its leader once per crewed ship. This is the fastest skill growth in the game; + see [Gaining and Losing Skill](./auto-traders.md#gaining-and-losing-skill). +- **upgrades a ship below upgrade level 2** through `0x0051A750(ship, 1)`. +- **drops the owner link of a badly damaged ship.** At `0x00514C5F`, a ship arriving with + less than half its maximum hull has `field_15D` set to `0xFF`. Since that field is what + marks a raider as somebody's hired pirate, a privateer that limps home stops being its + owner's: it is repaired at the hideout and put back to sea as a free pirate, with nothing + in the interface to say so. + +## Pirate Convoys +The [ships tick](./ships.md) keeps three chains, and an at-sea pirate (status `0x12`, +case `0x00507099`) is pulled out of the at-sea chain and given a **convoy record** of its +own through `0x005062F0`. If its health has reached zero it is removed instead. From then +on the pirate is driven by the convoy loop, case `0x00507A65`, once per tick: + +1. if the ship is not in the x-sorted neighbour list (`field_A`/`field_C` both `0xFFFF`), + `0x0050CE50` inserts it; +2. the restraint counter `convoy+0x16` counts down by one and is mirrored onto the acting + ship's `field_138`; +3. if `convoy+0x14` bit `0x40` is set, the current prey is re-validated and the pirate + engages; +4. otherwise, **only on every fourth tick**, it looks for prey with `0x0050D9E0` and asks + `0x00515360` whether it may attack; +5. engaging unlinks the convoy and calls `0x0050BC40`, which sets convoy status `0x14` and + hands both parties to the [sea battle](./ships/sea-battles.md) subsystem; +6. with no prey it advances along its course (`0x00502110`), and on arrival unlinks and + removes the ship. + +A pirate always gets a convoy record even when it sails alone, because every comparison +below is made fleet against fleet. Packs of one to three ships have been observed. + +## Finding Prey +`0x0050D9E0` keeps the current target as long as it is still at sea and inside the chase +radius; only when that fails does it rescan, walking the neighbour list in both +directions and taking the **nearest** candidate inside the acquisition radius. The radii +live on the ships container and are savegame state, not constants: + +|Field|Meaning|Observed| +|-|-|-| +|`+0xFA`|acquisition radius, squared|10000, i.e. 100| +|`+0xFC`|chase radius, squared|12100, i.e. 110| +|`+0xFE`|how far the neighbour walk may run in x|100| + +Because the current prey is sticky, a pirate will shadow one ship for days while a closer +one sails past untouched. + +Three rectangles in `.rdata` are excluded outright - the map's three rivers. The test is +`0x0050E340`, and the boxes are read from `0x00673564` as x0, x1, y0, y1: + +|x0|x1|y0|y1| +|-|-|-|-| +|442|658|1521|1691| +|2265|2332|147|453| +|1497|1578|1263|1444| + +## The Decision to Attack +`0x00515360` takes the pirate's ship index and decides whether the latched prey may be +attacked. In order: + +- **Never its owner.** `field_15D` holds the merchant a pirate belongs to; that merchant's + ships are skipped, which is what protects a player's fleet from his own hired pirate. +- **Never a protected merchant.** If the prey's owner has bit `0x4` in `merchant+0x8`, the + ship is skipped. In a live 24-town game that bit is carried by merchants 0..23 - exactly + one per town, hometown equal to index - and across 27 observed raids not one victim came + from that group. The same bit also excludes those merchants from the letter that + operation `0x3E` posts and from the dynamic name registry used by operations `0xB6` and + `0xB8`, so it marks a static background merchant rather than a piracy rule as such. +- **The restraint counter.** For an AI-owned ship the pirate's `field_138` must be exactly + `0`; for a **player-owned** ship anything up to `0x900` (nine days) will do. +- **Worth robbing at all.** Both branches also require the owner's **rank in his home + town** plus the Pirates activity setting to reach 2. At *high* activity that is + satisfied by any rank, at *normal* it needs rank 1, at *low* rank 2 - so the lowest + ranks can be beneath a pirate's notice, and the more the setting is turned down the more + established a merchant has to be before he is worth attacking. Rank is the byte at + `merchant + 0x39C + town`, computed in front of + [update_merchant_reputation_and_value](./ch05-03-reputation.md) from the per-town + reputation float at `merchant + 0x2FC + town*4` and the company value at `+0x46C` + (see [Ranks](./ch05-01-ranks.md#where-the-rank-is-stored)). +- **The prey must be carrying cargo** (`field_118` greater than zero) and the pirate's own + convoy must have at least `0x7D0` raw capacity free - one load - to hold the loot. +- **Speed.** With the per-ship speed of `0x00612930`, the prey convoy's slowest ship + against the pirate's fastest: `19 * prey > 20 * own` refuses. The prey may be about 5% + faster and no more. +- **Strength.** `7 * prey > 10 * own` refuses, so a pirate attacks while it has at least + **70%** of the target's strength. Strength is summed over a convoy as + `sum(crew) + sum(max(crew, artillery))`, using `field_40_crew` and + `field_120` (see [Ship Artillery](./basics/ship-artillery.md#combat-power)). For a prey + ship with no convoy the game uses plain `crew + artillery` instead, so putting a lone + ship into a one-ship convoy changes - and for an unarmed ship doubles - how strong it + looks. +- Finally the distance: within 20 units it attacks, otherwise it sets course with + `0x00516840` and keeps closing. + +Neither cutlasses (`field_154_cutlasses`) nor the captain nor the ship's health enter this +comparison. Health and the captain's navigation skill act on the *speed* term instead, and +the captain's combat skill is not read here at all. + +## The Raid Cycle +Measured across 27 battles in two campaigns, at 256 ticks per day: + +|Event|Value| +|-|-| +|A raiding party leaves its hideout with|counter 0 - free to strike at once| +|Every observed battle against an AI ship began at|counter exactly 0| +|Battles against a player's ship began at|0, and at 1956, 2158 and 2254 - 7.6 to 8.8 days| +|A battle lasts|45..116 ticks, a quarter to half a day| +|After a battle the counter is set to|`0xA00`, ten days (`0x0050C043`)| +|...plus a further|`0x700`, seven days, when the pirate also heads home| +|Rest observed|exactly 10.00 or 17.00 days, nine of each, nothing between| +|Raids world-wide with two or three parties active|roughly one a week| + +The seven extra days come from `0x0050603A`, which adds them when the pirate is given its +hideout as a destination; that value then reaches the convoy counter because +`0x00501CC1` loads `convoy+0x16` back out of the acting ship's `field_138`. + +What sends a pirate home is a fitness check, not a timer. In `0x00505C90` it breaks off +when its health drops below 80% of maximum or its artillery falls under 18 power - two +small catapults' worth (`0x00505D43`). 80% is exactly where the speed term stops +saturating, so a raider retreats at the moment damage starts costing it speed. + +The practical consequence of the counter is an asymmetry in the player's disfavour: an AI +merchant's ship can only be taken in the single tick the counter is zero, while a player's +ship is fair game for a nine-day window in every cycle. + +## Ship Fields +|Field|Meaning| +|-|-| +|`field_15C_is_pirate`|set by operation `0x0D` (`0x005386C0`) on a ship that is at sea; also ORs `0x18` into `field_3D`. This is the flag a hired pirate's ship carries| +|`field_15D`|the merchant a pirate belongs to, `0xFF` for a free pirate. Written by the ship spawner `0x00509250` from the ship's own merchant index, and cleared again if the ship reaches a hideout under half hull| +|`field_158`|the band index, 0..4. The getter `0x0051A470` falls back to the first surviving band, so an orphaned raider re-homes itself| +|`field_159`|an assigned town index, stored only for ships without a real owner (`0x0051A83B`), otherwise `0xFF`| + +`field_158` and `field_159` are late additions: the ship loader only reads them when the +savegame version is at least `0x79`, defaulting them to 0 and `0xFF`. + +A pirate ship that puts into a town whose `+0x2C8` lacks flag `0x04000000` is removed +outright (`0x00506DEB`), which is why pirates are only ever seen entering their hideouts. + +Capturing a pirate clears `field_15C` and normalises the status, so a prize behaves like +any other ship - it keeps its name, its index and a now-meaningless band number. It +arrives stripped: one captured hull came with 8 crew against a complement of 29, 55% hull, +and an empty two-slot artillery position where a bombard had been shot away. + +## Operations and Tasks +|Opcode|Effect| +|-|-| +|`0x0D`|set or clear `field_15C_is_pirate` on a ship at sea (`0x005386C0`)| +|`0xB4`|`0x00542E40`, which reaches the pirate ship creator| +|`0xB5`|create a pirate ship (`0x00514D40`) for the band named by the operation's first argument| +|`0x99`|form or join a convoy (`0x0050B250`) - works for a single ship| + +Task `0x08` (`0x004E2634`) is unrelated to these ships: it maintains the tavern +population of captains and pirate captains, and is described under +[Auto Traders](./auto-traders.md). diff --git a/src/scheduled-tasks/0000.md b/src/scheduled-tasks/0000.md index 2b740e9..401f654 100644 --- a/src/scheduled-tasks/0000.md +++ b/src/scheduled-tasks/0000.md @@ -32,20 +32,72 @@ The scheduled task's opcdode field denotes which kind of task it is. Some task types are recurring, and reschedule themselves immediately when they are executed. The data field is a union containing all possible task arguments. -## Identified Tasks -The following scheduled tasks have been identified: +## The Task Table +The dispatcher switches on the opcode through the jump table at `0x004D8A28`, which +covers opcodes `0x00`..`0x39`; anything above falls through to the requeue tail. Three +opcodes share a handler with another (`0x23`, `0x24` and `0x34`; `0x0a` and `0x36`; +`0x1a` and `0x37`), and the handlers of the identified tasks are linked below. -|Opcode|Task| -|-|-| -|0x01|Debt Repayment| -|0x05|Crime Investigation Result| -|0x06|Update Shipard Experience| -|0x07|Celebration| -|0x0c|Land Transport Arrival| -|0x15|Marriage| -|0x1a|Update Sailor Pools| -|0x2e|Council Meeting| -|0x35|Unfreeze Harbor| +|Opcode|Handler|Task| +|-|-|-| +|`0x00`|`0x004DB330`|| +|`0x01`|`0x004DB4E0`|Debt Repayment| +|`0x02`|`0x004DBB00`|| +|`0x03`|`0x004DDA40`|[Ten-Day Update](./0003-ten-day-update.md)| +|`0x04`|`0x004DFC94`|| +|`0x05`|`0x004E5B84`|[Crime Investigation Result](./0005-criminal-investigation.md)| +|`0x06`|`0x004E2144`|[Update Shipyard Experience](./0006-update-shipyard-experience.md)| +|`0x07`|`0x004E23A4`|[Celebration](./0007-celebration.md)| +|`0x08`|`0x004E2634`|Captain and Pirate Spawning ([Auto Traders](../auto-traders.md))| +|`0x09`|`0x004E2824`|| +|`0x0a`|`0x004E2CD4`|| +|`0x0b`|`0x004E37F4`|| +|`0x0c`|`0x004E38B4`|Land Transport Arrival| +|`0x0d`|`0x004E4984`|| +|`0x0e`|`0x004E4A44`|| +|`0x0f`|`0x004E5664`|| +|`0x10`|`0x004E63E4`|| +|`0x11`|`0x004E6744`|| +|`0x12`|`0x004E6A54`|| +|`0x13`|`0x004E7A14`|| +|`0x14`|`0x004E7BB4`|| +|`0x15`|`0x004E7C74`|Marriage| +|`0x16`|`0x004E8234`|| +|`0x17`|`0x004E8684`|| +|`0x18`|`0x004E8804`|| +|`0x19`|`0x004E8E24`|| +|`0x1a`|`0x005303C0`|[Update Sailor Pools](./0026-update-sailor-pools.md)| +|`0x1b`|`0x004ECF64`|Letter and Mission Scripts ([Letters](../letters.md))| +|`0x1c`|`0x004E9094`|| +|`0x1d`|`0x004E9564`|| +|`0x1e`|`0x004DFD44`|| +|`0x1f`|`0x004E0334`|Pirate bands ([Pirates](../pirates.md))| +|`0x20`|`0x004DBBD0`|| +|`0x21`|`0x004DC1F0`|| +|`0x22`|`0x004DB7C0`|| +|`0x23`|`0x004E09D4`|Pirate bands ([Pirates](../pirates.md))| +|`0x24`|`0x004E09D4`|Pirate bands ([Pirates](../pirates.md))| +|`0x25`|`0x004E1B84`|Pirate bands ([Pirates](../pirates.md))| +|`0x26`|`0x004E1E54`|| +|`0x27`|`0x004DDC00`|Take a retiring captain off his ship ([Auto Traders](../auto-traders.md))| +|`0x28`|`0x004DDF40`|| +|`0x29`|`0x004DDE70`|| +|`0x2a`|`0x004DE4F0`|| +|`0x2b`|`0x004DE790`|| +|`0x2c`|`0x004DEA20`|| +|`0x2d`|`0x004E2BC4`|| +|`0x2e`|`0x004E9A94`|[Council Meeting](./002e-council-meeting.md)| +|`0x2f`|`0x004EA0D4`|| +|`0x30`|`0x004EA594`|Pirate bands ([Pirates](../pirates.md))| +|`0x31`|`0x004EAB94`|| +|`0x32`|`0x004EAFE4`|Pirate bands ([Pirates](../pirates.md))| +|`0x33`|`0x004EC934`|| +|`0x34`|`0x004E09D4`|Pirate bands ([Pirates](../pirates.md))| +|`0x35`|`0x004E94A4`|[Unfreeze Harbor](./0053-unfreeze-port.md)| +|`0x36`|`0x004E2CD4`|| +|`0x37`|`0x005303C0`|| +|`0x38`|`0x004ECB14`|| +|`0x39`|`0x004ECDF4`|| ## Related Functions |Address|Function|Description| diff --git a/src/scheduled-tasks/0003-ten-day-update.md b/src/scheduled-tasks/0003-ten-day-update.md new file mode 100644 index 0000000..ab2018e --- /dev/null +++ b/src/scheduled-tasks/0003-ten-day-update.md @@ -0,0 +1,121 @@ +# Ten-Day Update +The task with opcode `0x03` (`0x004DDA40`) is a periodic sweep over several subsystems. +The dispatcher reschedules it at `0x004D8668` with `due += 0xA00` = 2560 +[ticks](../basics/time.md), so it runs **once every ten days**. + +This page covers the part of it that maintains the world's +[auto traders](../auto-traders.md): the call to `0x004DCEA0`, which is the only thing in +the game that grows a captain over time. + +## What one run does +For every merchant, for every ship of that merchant: + +- skip the ship if its status is `0x11`, if it carries no valid captain index, or if the + captain's index does not belong to this run's **group** (below); +- retire the captain if he is old enough (below); +- give the captain a skill gain, through + [operation `0x12`](../operations/0012-auto-trader-skill-gain.md). + +Then, **only for a human merchant** (`merchant+0x8` = 0), for every office of that +merchant (`merchant+0xC`, chained through `office+0x2C8`): with the same group filter and +a `(rand & 0x3FF) < 0x1B3` roll, raise the administrator's trade skill by one level +through [operation `0x67`](../operations/0067-administrator-skill-gain.md). + +Nothing requires a ship to be sailing, carrying cargo or doing anything at all, and a +record sitting in a tavern is never reached - the sweep only ever walks merchants' ship +chains. + +### The two growth paths +The owner's control word decides which. + +An **AI merchant's** captain (`merchant+0x8` non-zero) gets a flat `8` in both gain fields, +applied by calling the operation switch `0x00535760` directly - no queue, and no threshold +test at all. All three of his skills rise by 8 whenever his group comes up, until each +meets its own ceiling. + +A **human player's** captain (`merchant+0x8` = 0) gets one roll of `rand & 0x3FF`, taking +one of three branches: + +|Roll|Gain field written|Skill whose threshold is tested| +|-|-|-| +|`0x000`..`0x155`|navigation|navigation| +|`0x156`..`0x2A9`|trade and combat|trade| +|`0x2AA`..`0x3FF`|trade and combat|combat| + +The gain is `rand % 51`, so `0`..`50`. Nothing is enqueued unless the skill in the third +column is **below the record's navigation ceiling** `T`: the enqueuer reads a second copy +of the ceiling table at `0x00672824`, indexed with the run's group, which for a ship that +passed the group filter is the record's own `index & 3`. The ship is skipped before the +roll if all three skills have already reached `T`. The two lower branches differ only in +which threshold they test, because +[both write the same field](../operations/0012-auto-trader-skill-gain.md#trade-and-combat-share-one-gain-field). + +Each enqueue also costs one slot of the operation queue's headroom +(`0x34 - [0x006DF346]`, read once at the top of the run); once that is spent the rest of +the run is silently dropped. + +#### What that means for a captain's final skills +Navigation is simple: its rolls fire while navigation is below `T`, and `T` is also +navigation's own ceiling, so navigation ends at exactly `T`. + +Trade and combat are not. A roll on either pays **both**, so the pair keeps growing while +the **lower** of the two is below `T` - the laggard's rolls carry the leader along, past `T` +and on toward the leader's own ceiling, where the clamp stops it. Once both are at or above +`T` neither branch can fire again and both freeze wherever they stand. The last gain before +that comes from just under `T` and is at most 50, so the lower of the two ends somewhere in +`T`..`T+49` and stays there for the rest of the captain's life. + +One slot therefore produces very different careers. Take a record whose navigation ceiling +is 150 and whose trade and combat ceilings are both 250: + +|Skills as created|Where they end up| +|-|-| +|trade and combat both low|they cross 150 together and stop between 150 and 199 - displayed level 3 or 4, never the level 5 their own ceilings would allow| +|trade 170, combat 15|combat's rolls keep paying trade, which reaches its 250 ceiling and is clamped there, while combat is dragged up to 150..199 - so trade does finish at level 5| + +Measured on a live save: a captain with `T` = 150 took combat from 205 to 240 over nine +months purely because his trade was sitting at 16, and both will stop the moment that trade +reaches 150. + +### Retirement +`field_4` of an auto-trader record is a birth stamp in ticks: the initializer writes +`game_time - offset` with `offset = 46720 * (48..79) + 1792 * (0..31)`, an age of 24.0 to +40.1 years at creation. A run retires a captain once that age passes `0x474A00` ticks = +18,248 days, almost exactly **50 years**, and only if `field_E` is still clear: + +- an **AI** merchant's captain is retired at once - schedule task `0x27` (`0x004DDC00`) + three hours out, set `field_E`; +- a **human** player's captain gets a roll instead: `(rand & 0x3FF) * (age >> 13)`, + floored to a multiple of 1024, must exceed `0x93000`. The product cannot clear that bar + until the captain is about 51.6 years old, and the chance grows from there. When it + fires it enqueues + [operation `0x13`](../operations/0013-captain-retirement.md), which schedules the same + task and sends the player a message. + +## The group, the counter, and the annual halt +A run does not touch every captain. It only looks at records whose `index & 7` equals a +**group** number, so a given captain comes up about every eighth run - roughly every 80 +days, four times a year. + +The group is `counter & 7`, and the counter lives in the task's **own data** at `+0x8`: + +- a run landing on a **day of the year below 10** resets it to `0`; +- any other run increments it by one; +- **if it is above `0x1F` the routine returns immediately** - no captain is looked at, no + one ages, no administrator gains. + +A year holds about 36.5 runs, so the counter normally walks `0` to `~36` and the last +handful of runs in each year do nothing at all. + +The counter is part of the saved game, and a scenario can therefore ship with it already +past the cut-off. Measured on the stock campaign starting 1 April 1362: the counter reads +**117** at the campaign's own start date, and because that year's one run inside the reset +window falls before the start date, the next reset is 1 January 1363. For those nine +months no captain in that campaign grows, ages, or gains administrator skill. A save from +the same campaign 259 days after the reset showed 35 records being cut back to their +ceilings once the sweep resumed. An open-ended game starts the counter at `0` and a +campaign starting in 1305 was measured resetting every year, so this is a property of the +scenario, not of campaigns in general. + +## Interval +Rescheduled by the dispatcher, not by the handler: `due += 0xA00`, so every ten days. diff --git a/src/ships.md b/src/ships.md index c40f9bf..a3a60ee 100644 --- a/src/ships.md +++ b/src/ships.md @@ -66,7 +66,7 @@ The following fields have been identified: 0000003C char field_3C; 0000003D char field_3D; 0000003E signed __int16 field_3E_maintenance; -00000040 unsigned __int16 field_40; +00000040 unsigned __int16 field_40_crew; 00000042 unsigned __int16 field_42_captain_index; 00000044 int field_44_timestamp2; 00000048 int field_48_maybe_calculated_arrival_timestamp; @@ -76,13 +76,13 @@ The following fields have been identified: 000000B4 float field_B4_avg_prices[24]; 00000114 int field_114_payload_buy_sum; 00000118 int field_118_maybe_used_capacity; -0000011C int field_11C_maybe_arty_weight; +0000011C int field_11C_equipment_weight; 00000120 int field_120_arty_stuff; 00000124 int field_124; 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; @@ -90,7 +90,7 @@ The following fields have been identified: 0000013A char field_13A; 0000013B char field_13B; 0000013C char field_13C_artillery[24]; -00000154 int field_154; +00000154 int field_154_cutlasses; 00000158 int field_158; 0000015C char field_15C_is_pirate; 0000015D unsigned __int8 field_15D; @@ -98,3 +98,96 @@ The following fields have been identified: 00000160 char field_160_ship_name[32]; 00000180 }; ``` + +## Crew, Cutlasses and the Equipment Weight +Three fields describe what a ship carries besides cargo, all verified in-game by +changing one thing at a time and diffing the struct: + +|Field|Meaning| +|-|-| +|`field_40_crew`|sailors aboard; `0x005184F0` derives the complement as `max(20, capacity/2000 + upgrade_level * class_factor)`, the class factors being 3, 5, 8, 10 (first dword of the per-class blocks at `0x0066E030`, stride `0x18`). `capacity/2000` is the capacity in loads, so a ship's crew is roughly its load capacity.| +|`field_154_cutlasses`|cutlasses aboard (they are not [ship weapons](./basics/ship-artillery.md))| +|`field_11C_equipment_weight`|the cargo space all of that occupies| + +`field_11C_equipment_weight` is the sum of three terms: + +``` +400 * max(0, crew - class_base) + 10 * cutlasses + sum(weapon scaling factors) +``` + +The class base is the per-class reference crew at `0x00673664` = 10, 16, 30, 24 - crew up +to that allowance is free, and only sailors above it cost hold space. Measured on a +type 1 ship (base 16) with 34 crew, 49 cutlasses and 4 large + 2 small catapults: +`400*18 + 10*49 + (4*2000 + 2*1000)` = 17690, exactly the value in the field. A captured +type 2 hull (base 30) carrying only 8 crew, 27 cutlasses and 7 bombards + 2 small +ballistas gave `0 + 270 + 16000` = 16270, also exact - so an under-crewed ship gets no +credit for the unused allowance. Free cargo +space is therefore `field_10_capacity - field_11C_equipment_weight - loaded wares`, +which is the arithmetic the pirate AI uses when it checks whether it has room for loot. + +`0x005182B0` is where the game maintains all of this. Called on a ship it floors every +ware's cargo to a whole unit, then recomputes `field_114` (the payload's purchase value), +`field_118` (used space), `field_11C` by exactly the formula above - `0x0051838A` for the +crew term, `0x00518411` for the cutlasses term - and `field_120`, and **returns the free +cargo space**. That return is the budget an +[auto trade route stop](./auto-traders.md#running-a-route-stop) spends on loading. + +## Speed +`0x00612930` computes a ship's current speed, and it is the number the +[pirate AI](./pirates.md) compares when it decides whether it can run a target down. It +returns 1 for a ship with no capacity or no maximum health, otherwise: + +``` +base[ship_type & 3] // 693, 693, 578, 578 at 0x0067ADC0 + * (4096 - 614 * used_capacity / capacity) / 4096 // a full hold costs 15% + * clamp(179 * health / max_health + 113, 166, 256) / 1024 + * (2550 + navigation_skill) / 2550 // the captain +``` + +- A **full hold** costs about 15% of the ship's speed. +- **Damage** costs up to about 35%, and the clamp means anything above roughly 80% of + maximum health gives the full term - which is also the threshold at which a damaged + pirate breaks off and sails home. +- The **captain's navigation skill** is worth up to +10% (skill 255; the displayed level 5 + is skill 215, so +8.4%, each level of 43 points being +1.7%). A ship with **no** captain + skips the factor entirely and so matches a navigation-0 captain: captains never make a + ship slower. + +## 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 88a65c3..ab27401 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 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 ? @@ -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/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..4dfd5d8 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,29 @@ 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 holds NOMINAL production at full utilization: + // 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.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 +167,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..ff8c3f1 --- /dev/null +++ b/src/ui.md @@ -0,0 +1,300 @@ +# 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| +|`0x006E5574`|tavern window| +|`0x006E557C`|trading office window| +|`0x006E558C`|town hall window| +|`0x006E55C0`|shipyard window| +|`0x006CBA74`|auto trade goods dialog ("Automatic maritime trading")| +|`0x006E51AC`|local map scene (town view AND sea battle - see below)| + +About twenty more statics in the `0x006E5500`-`0x006E55D0` cluster hold further +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 static lives in that cluster, so failing to find one there does not mean +there is none: the scrollmap's +[ship panel](./ui/ship-panel.md) keeps its instance in `0x006CE6D0`, +stored by the same mass-constructor at `0x00426700`. + +## 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 at `0x006DA5F0` (also reachable through `0x004B9730`) tracks the open +windows in two containers. + +The registration list (`this+0xC0`): `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). + +The **window stack** (an MFC-style list at `this+0x4`): `+0xC` points at the TOP +node, `+0x10` holds the depth, and each node is `{+0x4: link toward the bottom, ++0x8: the window object}`. Windows enter through the push method +`0x004B90E0(window, arg)` (activates via vtable `+0xD4`/`+0x15C`, inserts the node +via `0x0064E6FC`) and leave through the remove method `0x004B9150(window)` (finds +the node from the top, unlinks it via `0x0064E749`, notifies via vtable +`+0xD4`/`+0x160`) - both thiscall on `0x006DA5F0`, with 47 and 43 call sites. + +The stack is what runs the game: the main loop is +`while (0x004B8A40(this = 0x006DA5F0) != -1)` (the loop itself at `0x004B70C0`), +and each frame that method pumps messages, updates the +[frame clock](./basics/time.md#the-frame-clock) and calls the TOP window's vtable +`+0xF4` (update) and `+0x12C` (`0x004B8B0D`). Scenes - scrollmap, town view, sea +battle - are window objects on the same stack as the building windows and dialogs, +so "which scene is the player looking at" is a read of the top node. + +## The Local Map Scene +One window object serves both the town view and the sea battle - what differs is +the map loaded into it. It is allocated at `0x00424DD4` (0xCBA8 bytes, constructor +`0x00586FF0`), kept in the static `0x006E51AC`, and carries two vtables: the main +one at `0x00677998` and a second interface at object `+0x94` (`0x00677990`). The +main vtable ends around `+0xF8` - unlike the building windows there are no +close/open slots at `+0x118`/`+0x120`. + +Its per-frame update (`+0xF4` = `0x0058B7F0`) drives the entire scene frame - +simulation, battle AI, the wind (`0x006113C9`), the changed-rect submit +(`0x004B9650`) - and paces the simulation purely by the +[frame clock](./basics/time.md#the-frame-clock): neither the game tick nor the +call count matters (calling the update several times per frame moves nothing). + +`+0xC324` holds the loaded map's id. Two loaders write it - `0x0058A733` stores the +id as given, `0x0058A395` sets bit `0x80` first (`or al,0x80`) - and `0xFF`/`-1` +mean no map (`0x00589DEE`, `0x0058B590`); the scene's own update starts with +`and eax,0x7F` and a compare against the town count to pick the town record. The id +space is only partly mapped, and "is a battle running" is NOT decidable from it: +towns attacked from the sea fight on the town's own map. The map files +(`iso/towns/.*`) come as ids 0..30 (the towns), 128..155, 201..205 (five - +matching `SeaBattleShaderCnt=5` in `scripts/iso.ini`) and 251..255; which class +means what has not been pinned down. + +## 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`, the town hall at `window + 0x18C8` (used at `0x005E40C2`, in a +function that also writes the window's `+0x1930` refresh timestamp). + +|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 +Every drawing call named on this page is a thunk into `ddraw_Dll.dll`, the +[SGL graphics library](./graphics.md) - that page lists which export each thunk +address resolves to. + +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 +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 resolved +at startup into slots in BSS, and the game calls them through a block of +`jmp [slot]` thunks at `0x004BAEC0`-`0x004BBB30` - the +[graphics library](./graphics.md#how-the-executable-binds-it) page has the table +that says which export each thunk resolves to. + +Text is one of them: `0x004BB3F0` is `jmp [0x006DAA04]`, which resolves to +`sgl_DrawText_Rect` (`ddraw_Dll+0xF100`, cdecl, fourth argument the C string). +It 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..6f7c0cd --- /dev/null +++ b/src/ui/auto-trade-goods-dialog.md @@ -0,0 +1,85 @@ +# 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 order type, i32 per ware, **20 entries** (the dialog has no weapons rows): 0 unload, 1 sell, 2 load, 3 buy, 4 no order| +|`+0x4ADC`|ware index of the pending (uncommitted) edit, `-1` = none| +|`+0x547C`|flag byte, third argument of populate| + +## Order Types +The order type is the dialog's own cache of what the stop record encodes in the signs of +price and amount; nothing outside the dialog reads it. `populate` derives it, splitting +the two office transfers by the amount's sign at `0x00405E30`: + +|Mode|Order type|Record| +|-|-|-| +|0|unload ship -> office|price 0, amount < 0| +|1|sell to town|price > 0 (the minimum price)| +|2|load office -> ship|price 0, amount > 0| +|3|buy from town|price < 0 (the maximum price, negated)| +|4|no order|amount 0| + +The row's single button cycles in one direction and wraps, verified in-game by click +count: + +``` +no order -> buy -> sell -> load -> unload -> no order +``` + +## Per-Ware Control Arrays +Ten pointer fields at `+0xF0` to `+0x114`, zeroed by the constructor (`0x00403148`), each +holding a `new[]` array of 20 objects of one widget class (constructor `0x004C6910`, +destructor `0x004C6A30`, element size `0xE8`) built through the array helper +`0x00639E9C`. Consecutive allocation makes the stored pointers come out evenly spaced by +`0x1238` (20 x `0xE8`, plus the array count header, rounded up), which is a handy sanity +check when reading them out of a live dialog. + +Each array is one column of the row. Reading element 0's rectangle out of a live dialog +(the class is in the window family, so x is at `+0x14`, y `+0x18`, w `+0x2C`, h `+0x30`) +identifies them: + +|Field|x|w|Column| +|-|-|-|-| +|`+0xF0`|0|96|ware-name button| +|`+0xF4` .. `+0x104`|562|48|the order-type button, five appearances sharing one rectangle| +|`+0x10C`|614|16|amount `-`| +|`+0x108`|708|16|amount `+`| +|`+0x114`|766|16|price `-`| +|`+0x110`|834|16|price `+`| + +The order type selects among the middle five: `dialog + 0xF4 + mode*4` gives the array for +a ware's current type, and populate registers that one with the +[window manager](../ui.md#window-manager) (`0x004B4E30`, deregistering the previous with +`0x004B4EB0`). Because all five share a rectangle, they are five appearances of a single +control rather than five separate widgets - so the block is a sub-range of the ten, not a +self-contained table, and an order type of 5 would reach the amount `+` button. + +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 [ship panel](./ship-panel.md)'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..fccfc5e --- /dev/null +++ b/src/ui/name-banks.md @@ -0,0 +1,30 @@ +# 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, 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 +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/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 new file mode 100644 index 0000000..3cc8e04 --- /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 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. diff --git a/src/ui/ship-panel.md b/src/ui/ship-panel.md new file mode 100644 index 0000000..386b7af --- /dev/null +++ b/src/ui/ship-panel.md @@ -0,0 +1,53 @@ +# Ship Panel +The scrollmap's right-side panel for the selected ship or convoy. Four buttons switch +its view - Goods, Crew, Deck and Auto trade - so the trade route is only one of the +things it shows, while the selection is common to all four. Vtable `0x0066F358`, +per-frame update `+0xF4` = `0x0048B3E0`. + +Its static pointer is `0x006CE6D0`. The object is built once at startup like the +building windows, but its static sits apart from the `0x006E55xx` +[window cluster](../ui.md#window-objects), which is why it long looked as if it had +none: the mass-constructor allocates `0x83C8` bytes at `0x004266D2`, calls the +constructor (`0x00486D20`) at `0x004266E7`, and stores the result at `0x00426700`. +Reading the static is enough - `p3-api` exposes it as `UIShipPanelPtr`. + +The object can also be captured by hooking the vtable slot at `0x0066F44C` (module +offset `0x26F44C`), which holds the update method; the hook receives the object as +`this` on every update. That was the original route and still works, but it is no +longer necessary. + +|Field|Meaning| +|-|-| +|`+0xA0`|pointer to the current selection; its leading u16 is the selected ship index, and nothing after that is written| +|`+0xA00`|Auto trade view: stop row widget structs, stride `0xE8`; `row + 0x3E` is set while that row's stop is open in the goods dialog| + +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 field is cleared to `0` while nothing is selected, so it never points at the +previous selection. Opening any building window also clears it - the ship is visibly +deselected - and closing the window selects the same ship again, so the panel selection +cannot be read while a building window is on screen. The selection object itself is +heap-allocated and freed when the selection changes (`0x00487E74` frees the old one +before storing the new pointer), which is why a lingering copy of the pointer must not +be followed. Only its leading u16 carries meaning: re-selecting the same ship leaves +different values behind it, matching the high halves of neighbouring heap pointers, so +the following bytes are uninitialised rather than a type tag. The value behind it is always a **ship** index, never a convoy +one: selecting a convoy on the map reports the convoy's leader, and picking an +individual ship out of a convoy reports that ship. (A convoy itself is a `0x3C`-byte +record in the array at `ships + 0x08`; a ship names its convoy in `ship + 0x08` and the +members are chained through `ship + 0x06`.) + +Two different buttons mean Goods, and only one of them opens a dialog. At the top of +the panel a barrel symbol switches the view, alongside Crew and Deck. Inside the +**Auto trade** view, every stop row carries a button labelled with the word "Goods", +and that one opens the [goods dialog](./auto-trade-goods-dialog.md) for the stop in +that row: it resolves the stop by walking the pool chain from `ship + 0x132` to the +first-stop marker and then forward by the row number (`0x0048C3A1`), and calls the +dialog's populate at `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).