-
Notifications
You must be signed in to change notification settings - Fork 0
Town Services
Navigation: Home - Game systems - Town Services
flowchart TD
tile["Tile trigger<br/>collision 0x80"] --> scan["Scanner 0x1754A"]
scan --> vm["Script VM 0x172CA"]
vm --> op0e["OP_0E selector byte"]
op0e --> svc{"Service handler"}
svc --> pub["Pub 0x1A132"]
svc --> inn["Inn -$7CD4"]
svc --> temp["Temple 0x1D208"]
svc --> guild["Mage guild"]
svc --> train["Training 0x9BCA"]
pub --> str["str.dat UI strings"]
inn --> exe["exe-embedded ASCII"]
temp --> spells["3 cleric spells/town"]
guild --> sorc["4 sorcerer spells/town"]
Reverse-engineered entry flow, ASM handlers, strings, and per-town tables for
the five surface towns (map screens 0–4). Cross-references:
[30-event-to-string-path.md](Event-to-String-Path) (how event.dat vs
str.dat vs exe text connect), [07-event-script-opcodes.md](Event-Script-Opcodes)
(OP_0E, OP_0B, OP_24), [06-roster-format.md](roster-dat-Format),
[11-str-decoded.txt](11-str-decoded.txt), [14-game-state-struct.md](Game-State-Struct),
[29-embedded-exe-strings.md](Embedded-Exe-Strings).
[TOC]
Data files: event.dat is in the repo root; full decode:
[EXTRACTED/event_decode.txt](../event_decode.txt),
[EXTRACTED/event_location_inventory.json](../event_location_inventory.json).
str.dat may still be absent — UI text from
[11-str-decoded.txt](11-str-decoded.txt). Regenerate:
python tools/decode_event.py event.dat > EXTRACTED/event_decode.txt
python tools/event_location_inventory.py event.dat EXTRACTED/event_location_inventory.json
python tools/mm2_codec.py str-decode str.dat EXTRACTED/docs/11-str-decoded.txt- Scanner
**0x1754A**reads(y,x)triplets; event id selects a script segment in the location’sevent.datrecord. - Typical service tile chain:
-
**OP_0B**— service title window (string index + mode byte). - Optional y/n text (
OP_01–OP_03,OP_09/OP_0A). -
**OP_0E**— one selector byte → engine handler (below). -
**OP_0F**— end script.
- Current map index is in
**A4-$79F2**(screen_mode_id, values 0–4 for the five towns). Shop/temple/training handlers index per-town tables with this byte (not the roster “home town” byte).
Chained subtraction on the selector byte:
| Selector | Name (decoder) | Handler | Role |
|---|---|---|---|
0x01 |
open_inn_lodging |
0x1A132 |
Inn registry y/n (home-town write); rest is world Rest @ 0x19E20
|
0x02 |
open_training |
-$7CD4 |
Training (level-up from XP) |
0x03 |
open_tavern_food |
0x1D208 |
Pub / food / drinks / rumors |
0x04 |
open_temple |
-$7D16 |
Temple services + donations |
0x05 |
open_mages_guild |
-$7D10 |
Mage guild spell shop |
0x06 |
open_blacksmith_shop |
0x1C54A |
Blacksmith (weapons/armor/specials) |
0x07 |
open_general_store |
-$7DB8 → 0xA62C
|
General store (Middlegate m11, Vulcania k6 only) |
0x08 |
open_arena_shop |
-$7DBE → 0x9D76
|
Arena Games ticket-combat-reward engine (byte-verified 2026-07 — see correction below; this is the ONLY path into 0x9D76) |
0x0A |
Nordon (default-range) |
0x15EDC → loc 60 |
Nordon goblet @ (10,2)/W (event 30, loc 60 str[9–15]). Not -$7DAC→0xD634 (that is sel 0x7F combat) |
0x0D |
enroll_mages_guild |
-$7DA0 → 0xD89C (+ $1A1F8?) |
Guild membership (20gp, shared string bank str[21]) |
0x64, 0x7E–0x83, 0xC9–0xCF, 0xE2, 0xFD
|
specials | various | Portals / quests |
| default | (event-loader reinvoke) |
0x15EDC → -$7DFA → 0x92F2
|
Selector binned to category 0x3C–0x46, then re-enters event_dat_loader (NOT the arena engine — see correction below) |
Correction (2026-07, revised): a first pass separated 0x07/0x08 from the
default-range dispatch, then wrongly claimed the default-range thunk -$7DFA
was the Arena Games engine (0x9D76). Byte-reading the A4 vtable trampoline
table directly out of EXTRACTED/ghidra/mm2_data_00.bin (doc 49, file offset
0x7FFE+disp) settles it:
-$7DB8 (file off 0x0246): 4EF9 0000A62C -> 0x0A62C general store
-$7DBE (file off 0x0240): 4EF9 00009D76 -> 0x09D76 Arena Games engine
-$7DFA (file off 0x0204): 4EF9 000092F2 -> 0x092F2 event_dat_loader (!)
So explicit selector 0x08 (not the default range) is the arena engine's sole
entry point; the default-range dispatch re-enters the event LOADER (the same
routine used to load a normal on-map location) with a synthetic index —
plausibly to pull one of event.dat's non-map "string bank" pseudo-records
(§1.5 below). This was fixed in game/src/events/EventTownServices.cpp (arena
logic moved to case 0x08; default: no longer shows the fabricated ticket
text) and in the web walker's selectorBin.js/eventVm.js. See doc 07 §OP_0E
for the full trace and the practical symptoms this caused (unrelated
default-range quest/reward tiles — including the game-start Corak monologue —
incorrectly demanding a ticket).
Decoder location 60 (Nordon/Nordonna/Corak, record kind: string_bank) has
no script segments of its own — it is a pure, town-invariant string pool
referenced by several generic quests. Notably str[21] is the REAL
enroll-mage-guild prompt ("A sleepy conjurer yawns...") and str[23]/str[25]
are the REAL Feldecarb Fountain farthing-flick prompt/failure text — see doc 07
§"event.dat shared cross-town string bank" for the full string listing. Nordon
intro str[9] is wired into case 0x0A; Feldecarb str[23] into default: when
sel == 0x0E (Middlegate event 17 @ (15,15)). Enroll str[21] in case 0x0D.
Default-path binning (from 0x15EDC, range helper -$7E9C):
| Selector range | Category | Index adjust |
|---|---|---|
0x09–0x10
|
0x3C |
-8 |
0x11–0x37
|
0x3D |
-0x10 |
0x38–0x4B
|
0x3E |
-0x37 |
0x4C–0x54
|
0x3F |
-0x4B |
0x56–0x5B
|
0x40 |
-0x55 |
0x5C–0x5E
|
0x41 |
-0x5B |
0x65–0x69
|
0x42 |
-0x64 |
0x6A–0x7C
|
0x43 |
-0x69 |
0x97–0x98
|
0x44 |
-0x96 |
0xE3–0xF3
|
0x45 |
-0xE2 |
0xF4–0xFB
|
0x46 |
-0xF3 |
During default dispatch the engine temporarily overwrites A4-$79F2 with
the category byte, calls -$7DFA, then restores the real map index. Explicit
0x06 blacksmith uses the real map index throughout.
Arena / Monster Bowl / guild spell counters use named selectors in the
default ranges (e.g. Middlegate 0x10, Sandsobar 0x49–0x54, Atlantium
Olympic Trial tiles 0x12–0x25).
-
**OP_24** —u16gold check →A4-$7951(cond_flag). Observed amounts:10,25,50,200,500,1000,2000,5000(training, travel, Olympic skills, etc.). - Training calendar mode
**1**/**2**(stat vs HP) is resolved @**0x9B48**before**0x9BCA**. Stat path calls**-$7EB4**with mode**1**@**0x9BC0**; HP path enters**0x9BCA**when mode**2**.
FAQ §3-6 (lines 1171-1175). Closed-form expressions; exact multiply/index sequence in
0x9B48/0x9BCA/0x1D208not yet ASM-confirmed.
Training = current_level × town_index × 50 gp
Healing = current_level × town_index × 10 gp
(dead: × 100 gp total = × 10 gp × 10)
(eradicated: × 1000 gp total = × 10 gp × 100)
Town index: Middlegate=1, Tundara=2, Sandsobar=2, Vulcania=4, Atlantium=5.
See [34-commerce-formulas.md](Commerce-Formulas) for town-index table,
day-bonus cycle, portals, bar food/drinks, and Fly landing coords.
Port status (game/src/events/EventTownServices.cpp, game/src/ui/PlayTownServiceUi.cpp,
wiki maze-walker/townServices.js). Selectors 1–8 no longer auto-apply
whole-party transactions (that was fabricated). With PlayTownServiceUi
bound (GameSession → EventRuntime::bindTownServiceUi), the remake runs
faithful interactive menus for temple, training, smith, mage guild, and tavern
(leaf ops in TownServiceTransactions.cpp). Entry flow differs by service
(see §1.4.3). Without a UI backend, each selector shows the real str.dat intro
(or OP_0B sign title) and defers the engine menu. Training/healing cost helpers
(eventVmTrainingCostPerChar / eventVmHealingCostPerChar) are implemented and
unit-tested. Temple heal cost builder 0x1DCA2 (condition $FF→×1000 base,
≥$80→×100, else ×10) is ported via townSvcTempleHealCost.
Correction: the jump table @
0x1D650lives inside0x1D208, whichOP_0Eselector0x03(tavern) calls. Temple isOP_0E 0x04→-$7D16→ per-member shell0x1DD8Ewith key dispatch0x1E0AA. An earlier draft of this section mislabeled the tavern table as the temple menu.
| key | leaf | traced behavior |
|---|---|---|
| A | 0x1D716 |
Heal: gold vs runtime cost (0x1DCA2 → A4-$56BE), then 0x1DD48 HP restore + clr.b $26
|
| B | 0x1D758 |
Restore alignment: gold vs 0x1DC3A cost, then move.b $d,$6a (current → base) |
| C | 0x1D796 |
Donate: gold vs A4-$6714[map]×100 (0x1DC1A), OR A4-$66B1[map] into A4-$799E
|
| D/E/F | 0x1D872 |
Cleric spell buy (0x1DAC6 prices from A4-$66F6) |
| G | 0x1D90C(0) |
Gather Gold — refresh display (deduct 0) |
Temple cost scale A4-$6714: BE u16 [1, 5, 2, 3, 2] by map → donate GP
100/500/200/300/200. Heal/align multiply this scale by level and a condition
base (0x1DCA2: $FF→1000, ≥$80→100, else 10 when afflicted/HP low).
Player-facing FAQ labels (A Restore Cond / B Restore Algn / C Donations / D–F spells) match this temple dispatch.
| key | leaf | traced behavior |
|---|---|---|
| A | 0x1CA2E |
Feeding frenzy: pay A4-$6742[map], top party food +$25 to 0x28
|
| B | 0x1CAC4 |
Stat-boost submenu (captions A4-$580E, costs A4-$6738) — not drinks |
| C | 0x1CD2E |
Food/specialties (A4-$6760 prices → 0x1C8D4 OR into +$76) |
| D | 0x1CFCA |
Tip bartender (1 gp + tip pool A4-$58AE) |
| E | 0x1D0B4 |
Rumors (A4-$594E) |
| G | 0x1C9C0(0) |
Gather Gold |
Ported (2026-07): B = townSvcTavernStatBoost / PlayTownServiceUi boost menu;
C = townSvcTavernSpecialty + townSvcFoodEncodePurchase (0x18EC0/0x019030 →
+$78 / +$7C bit0). Drinks remain on selector 0xCA (townSvcDrinkEncodePurchase),
not tavern key B.
Persistent footer strings (literal pool 0x1D68C+): "G-Gather Gold",
"#-Other Char", "Select (A-E)".
Heal/restore leaf (0x1D716, temple A): gold check 0x1D90C
(per-char record+0x66 ≥ cost) → on success 0x1DD48 (HP restore) + clr.b $26
(condition). HP restore (0x1DD48): if record+$5E (work max) < record+$60
(permanent max), set $5E and $74 (current HP) to $60.
Gold primitive (0x1C9C0 == 0x1D90C): scc (gold ≥ amount) then deduct;
no partial spend. The whole temple/training/shop engine charges the selected
character's own gold, not a pooled party total.
The faithful leaf mutations above are implemented byte-exact in
TownServiceTransactions.{h,cpp} and driven through a swappable UI backend
ITownServiceUi (TownServiceMenu.{h,cpp}) — interactive selection is pluggable
(CLAUDE.md), the costs/state are ASM-canonical. Per-town constants:
EXTRACTED/decomp/mm2_town_tables.{h,c} + tools/mm2_town_tables.py. Unit tests:
game/tests/event_middlegate_test.cpp (testTownServiceTransactions,
testPlayTownServiceUi) — heal deducts level×index×10 + restores HP +
clears condition; training level-up deducts level×index×50; donation deducts
A4-$6742[map] + sets the A4-$799E quest bit (0x1F all-five); smith buy
uses townSvcSmithBuy. See doc 07 §OP_0E "Town-service transaction layer".
When a town-service UI backend is bound, eventExecTownSelector follows the
original str.dat greeting pattern for pub/temple but not for the blacksmith:
OP_0E sel |
Service | str.dat NPC intro (y/n)? | Menu on Y (backend bound) |
|---|---|---|---|
0x03 |
Tavern | Yes — Amber / Rowena / Belinthra / Gabrielle / Lucindra (~lines 89–108) | A–E top menu (townSvcRunTavern → PlayTownServiceUi) |
0x04 |
Temple | Yes — five priest variants (~lines 343–357) | Heal / Restore Cond / Donate / cleric spells (townSvcRunTemple) |
0x06 |
Blacksmith |
Yes — Svendegard / Drewnhald / … (~255–273); gates on A4-$7951 @ 0x1C668
|
Category menu A–F after y/n |
0x02 |
Training | No — OP_0B sign title + SPACE only | Level-up trainee prompt (no stat sub-menu) |
0x05 |
Mage guild | str.dat hall intro when no backend; with backend, membership gate (0x1E410) then spell shop |
Four sorcerer spells/town |
0x01 |
Inn | No — OP_0B sign title + SPACE | Rest menu (partial port) |
Mechanism (C++): pub/temple/blacksmith call showServiceIntro → EventVmWait::YesNo →
on Y, EventRuntime::finishPendingTownMenu → eventTownServiceRunBoundMenu
(PendingTownMenu::Tavern / Temple / Smith). GameSession::tickEventInput then calls
maybeOpenTownServiceMenu() so the lower-band overlay opens in the same frame.
Training opens directly (no str.dat y/n intro).
Wiki maze-walker mirrors the same split in eventVm.js / townServices.js:
tavern + temple prompt with transcribed intros; blacksmith goes straight to
runSmithService. Tavern food/drink effects in the walker use simplified
fixed-gp tables (A4-$6760, PUB_DRINKS) — the ROM's RNG-gated cost leaves
(0x18EC0 / 0x18F78) and stat-bonus sick roll are still deferred (§13.3.1).
Still needs work (events / services broadly): general store (0x07),
tavern RNG drink/food VM paths, smith sell/identify/specials date-roll,
default-range -$7DFA reinvocation, Feldecarb farthing transaction (0x0A),
guild enroll (0x0D), and the bulk of per-location event.dat scripts beyond
Middlegate smoke tests. Arena (0x08) and Feldecarb farthing (0x0A) are
separate — do not conflate ticket combat with fountain farthings.
See [34-commerce-formulas.md](Commerce-Formulas) § Portal connections.
Portal selector bytes (0x64, 0x7E–0x83) are handled by the OP_0E
default-range path (§1.2 above, entry 0x64, 0x7E–0x83).
Map index = location id: 0 Middlegate, 1 Atlantium, 2 Tundara, 3 Vulcania, 4 Sandsobar.
AUTHORITATIVE placement — (y,x)/facing of the OP_0E selector tile. These
are the tiles the runtime tile-event scanner (0x1754A) actually triggers on,
decoded byte-exact from event.dat (python tools/decode_event.py --decompile,
service event = service_sign(OP_0B) ; exec_selector(OP_0E sel)). The remake's
EventRuntime::scanAndRun reads these same triplets, so port placement is
faithful by construction. (A character's cond byte is the facing mask: W
0x10 / S 0x20 / E 0x40 / N 0x80; ANY = 0xF0.)
The earlier version of this table was wrong (a stale/scrambled guess — it e.g. put the Middlegate Smith at
(7,7), which is actually the Training tile, and claimed Tundara had "noOP_0Ehits"). The Middlegate Smith(4,4)/Wis the known-good in-game reference; every other selector is corrected below.
| Town | Inn 0x01
|
Train 0x02
|
Tavern 0x03
|
Temple 0x04
|
Guild 0x05
|
Smith 0x06
|
Store 0x07
|
Notes |
|---|---|---|---|---|---|---|---|---|
| Middlegate | (3,7)/S | (7,10)/E | (6,4)/W | (7,7)/N | (14,7)/N | (4,4)/W | (10,12)/S | arena 0x08 (2,13)/ANY; Nordon goblet 0x0A (10,2)/W; Feldecarb 0x0E (15,15); enroll 0x0D (12,1)/W; portal 0x11 (5,0)/W |
| Atlantium | (13,9)/S | (4,10)/E | (10,12)/E | (7,4)/W | (4,5)/W | (13,6)/S | — | arena 0x08 (9,7)/ANY; Olympic/skill tiles 0x12–0x25 on separate tiles |
| Tundara | (11,7)/W | (6,11)/S | (9,7)/W | (13,11)/N | (13,14)/S | (10,11)/E | — | full OP_0E set present (the old "no hits" note was a cached-decode artifact) |
| Vulcania | (0,7)/S | (3,4)/W | (2,3)/W | (8,11)/W | (5,11)/S | (8,15)/E | (5,10)/S | many arena selectors 0x3E–0x48
|
| Sandsobar | (10,3)/E | (7,3)/E | (11,4)/N | (11,6)/E | (7,6)/E | (15,7)/N | — | arena 0x08 (8,13)/ANY; arena 0x49–0x54
|
Caveat: the tiles in the table below come from the same stale cached decode as the old §2 matrix and are door-label / sign tiles, not the
OP_0Eservice tiles — they do not line up with §2 (e.g. Middlegate'sOP_04"S.J. Blacksmith" door label is at(4,5), while the Smith serviceOP_0E 0x06is at(4,4)).OP_0B's argument is a sign-table key →NN.anm(0x15756lookup), not a string index, so do not treat these as the service prompt text. Use §2 (above) +tools/decode_event.py --decompilefor authoritative tiles.
| Town | Building | Tile | Title string (from cached decode) |
|---|---|---|---|
| Middlegate | Inn sign | (4,5) | Middlegate Inn |
| Smith sign | (6,5) | S.J. Blacksmith | |
| Pub sign | (6,7) | Slaughtered Lamb | |
| Service | (3,7) | Gateway Temple | |
| Service | (6,4) | Turkov's Training | |
| Atlantium | Pub | (13,6) | Boar's Tongue Tavern |
| Smith | (7,4) | Drewnhald Ironworks | |
| Training | (4,5) | Beautify Atlantium | |
| Temple | (15,15) | Island Training (title quirk) | |
| Tundara | Pub | (13,11) | Lucky Dog Saloon |
| Training | (9,7) | Enhancement Center | |
| Temple | (11,7) | White Dove Temple | |
| Smith | (6,11) | Thundrax Weaponry | |
| Vulcania | Pub | (3,4) | Belinthra's Bar |
| Smith | (8,11) | Bestway Blacksmith | |
| Training | (2,3) | Vulcanian Transport (title quirk) | |
| Sandsobar | Pub | (7,3) | Red Lantern Tavern |
| Smith | (11,6) | Big Al's Accessories | |
| Temple | (10,3) | Temple Benedictus | |
| Training | (11,4) | Sheik Training Arena |
Door labels (OP_04) match the same names where present.
From [11-str-decoded.txt](11-str-decoded.txt) — global strings used by engine
handlers (not per-location event.dat banks):
- Menu: A) feeding frenzy B) drink C) specialties D) tip E) listen for rumors.
- Drink lines A–F (Orc Beer … Mystic Brew).
- Food lines A–C per town menu block (three columns of hors d’oeuvres / soup / steak variants).
- Pub NPC intro flavor (sequential block ~lines 89–108):
- Amber — Middlegate Slaughtered Lamb
- Rowena — Atlantium Boar's Tongue
- Belinthra — Vulcania
- Gabrielle — Sandsobar
- Lucindra — (brawling bar; likely Sandsobar alternate / special)
- Rumor hint embedded in str pool: "Donate at all / temples" (~line 171) — pub rumor E), not hard-coded quest text.
- NPC intros (~lines 255–273) exist in
str.datfor the deferred (no-backend) path; when the remake UI backend is bound the shop opens without this y/n greet (§1.4.3). Intros: Svendegard, Morgan Drewnhald, smelter smith, friendly smith, tough old smith — map to towns by matching Drewnhald→Atlantium; align others with shop names above when play-testing. - Menu: A) Weapons B) Today's Specials C) Armor D) Misc E) Sell F) Identify.
- Five priest intro variants (~lines 343–357).
- Menu: A) Restore Cond B) Restore Algn C) Donations D–F) cleric spell purchase (no sorcerer stock).
- Donation feedback: "Your generosity is greatly appreciated", "Today you are blessed!".
- Guild hall intros (~lines 328–342).
-
**Sorry, you must be a member of this guild to purchase spells.** (~line 369).
- Training payment messages via shared "Not enough gold" / stat prompts.
- Inn:
**Crispy Critters Inn** enrollment string @0x1A2B8(post-enroll UI).
Uses **A4-$79F2** (map 0–4) directly. Two layers:
-
Static item IDs — builder
**0x1C00E**walks**town × 6 + slot**into data-hunk tables (category jump @**0x1C09E**):
| Cat | Menu | Item IDs (A4−) | Meta (A4−) |
|---|---|---|---|
| 1 | Weapons | $68EE |
$68D0 |
| 2 | Today's Specials | $683A |
$683A |
| 3 | Armor | $68B2 |
$6894 |
| 4 | Misc | $6876 |
$6858 |
6 slots per town per category — full matrices in
EXTRACTED/shop_tables.json → static_by_town.blacksmith.
2. RNG str captions only — open handler **0x1C54A** fills display pointer
runtime tables via **-$7DE2**:
| Table (A4−) | Layout | Purpose |
|---|---|---|
$5ACE |
10 × pointer | Weapon caption strings |
$5AA6 |
5 × 4 × pointer | Category headers (Weapons / Specials / …) |
$5A56 |
6 × pointer | Specials wording (item ids still from $683A) |
Today's Specials item pick (0x1C146, category 2): game date **A4-$79B6**
mod **0x1E** indexes static bytes **A4-$681C / $6816** — not a random item
pool. Discount/meta bytes copied in category 4 path.
Shop loop (0x1C714+): menu A–F → 0x1C25A category id → **0x1C00E**
inventory builder → buy/sell/identify (-$7DDC, -$7B78).
The blacksmith buy transaction is now implemented byte-exact and driven by
the swappable ITownServiceUi backend (townSvcRunSmith, townSvcSmithBuy):
| step | ASM | behaviour |
|---|---|---|
| condition guard | 0x1BE4C |
reject if selected char record+$26 != 0 (caption 8) |
| free slot | 0x1BE60 |
first empty backpack id-run record+$3A (full → caption 2 @ 0x1BE82) |
| price | 0x1BF16 |
base = items.dat gold (record+$12); meta==0 → base, else base*2 + (meta-1)*1000
|
| gold |
0x1BDD6 (=0x1C9C0) |
scc(record+$66 ≥ price) then subtract (no partial) |
| grant | 0x1BEBC |
write id→+$3A, charges→+$40, flags→+$46 (SoA backpack; matches OP_19) |
Per-category buy fields (0x1C00E + the misc swap 0x1C1CC): Weapons/Armor
put the magic + bonus in flags(+$46) and price; Misc puts the meta in
charges(+$40) with price_meta=0 (base gold); Specials flags is the
date-rolled bonus (0x1C146) — deferred. Static item-id/meta matrices are in
EXTRACTED/decomp/mm2_town_tables.{h,c} (mm2_smith_inventory/mm2_smith_price/
mm2_smith_buy_fields) + tools/mm2_town_tables.py; tests in
event_middlegate_test.cpp (testSmithTransactions).
Still deferred (engine): Sell (0x1BC26) and Identify (0x1B6E0) leaves
(presentation-heavy), the Today's-Specials date-roll bonus (0x1C146,
A4-$79B6→$681C/$6816), and the Merchant-skill half-price discount
(-$7F32 @ 0x1BFB4; FAQ §3-10 "cut in half for merchant prices").
Selectors **0x09–0xFB** map to category **0x3C–0x46**, not the
blacksmith handler, and not the Arena Games engine either (see the 2026-07
correction in §1.2/§1.4 above). 0x160AE calls the fixed thunk -$7DFA,
which byte-verification shows resolves to 0x092F2 — the same
event_dat_loader used to enter a normal on-map location, called again with
a synthetic category/index instead of a real map id. What that reinvocation
actually loads/runs is not yet reverse-engineered; a plausible lead is one
of event.dat's non-map "string bank" pseudo-records (§1.5 — decoder location
60 is one such record), since the category+index pair could plausibly key into
a table of such records, but this is speculation, not a trace.
**0x06 vs default:** only **0x06** enters 0x1C54A (full smith UI + 6
specials). 0x07/0x08 are each their OWN distinct fixed handler (-$7DB8/
-$7DBE), confirmed distinct from both each other and from the default-range
thunk by direct byte inspection of the A4 vtable trampoline table.
General store (0x07 → -$7DB8) is confirmed to target 0xA62C (byte-read
from the trampoline table, doc 49) — no longer just a plausible candidate:
- Buy loop
**0xA62C**(confirmed target of-$7DB8): Y/N shell (-$7D0A@0xA68C) → character pick (-$7F98mode0x31@0xA6F6) → affordability hard gate**record+$66 ≥ 100 gp**(cmpi.l #$64,$66(a0)@**0xA75E**) → purchase effect via the**0xA3AE**jump table (keyed on roster+$50nibbles). - Item pools are not the blacksmith
A4-$68xxmatrices; the general-store display reads per-slot bytesA4-$7136/A4-$713C(0xA812+) and menu-line pointersA4-$719E(0xA672).tools/dump_shop_tables.pydoes not yet dump these (unlike blacksmithA4-$68xx; extract via0x7FFE − dispinghidra/mm2_data_00.bin). - The real town index stays in
A4-$79F2for0x07(unlike the default-range path, which temporarily overwrites it with the category byte).
Reproducing this faithfully requires porting the -$7DB8 shell, so the remake
keeps the OP_0B sign-title + defer fallback (no fabricated buy). The OP_0E
dispatch + default-range binning themselves are already byte-exact
(eventVmIsTownServiceSelector).
Revised 2026-07 — byte-verified against the A4 vtable trampoline table
(doc 49): explicit OP_0E selector 0x08 (thunk -$7DBE), NOT the
default-range dispatch, is the sole path into 0x9D76. Full detail lives in
07-event-script-opcodes.md;
summary: whenever an event.dat script encodes selector 0x08 (e.g.
Middlegate's arena-entrance tile), the engine unconditionally scans the
party's backpacks for a ticket item 0xD0-0xD3, denies with a fixed string if
absent, otherwise consumes the ticket and arms a fixed combat encounter (same
battle-slot array as OP_12) using
monster_type = ((color*3 + area[screen]) * 16) + rng(1,16); on victory it
grants gold from a 4×3 (color × town-tier) table and prints "Winner, you receive N gold". Ported: ticket scan/consume + monster-type and gold-table
lookups (EventVmHelpers.h, eventVmFindArenaTicket /
eventVmArenaMonsterType / eventVmArenaGoldReward) and the real deny/accept
text (EventTownServices.cpp case 0x08). Not yet ported: reward
granting on combat victory (no combat engine exists in the port yet — same gap
as OP_12/13). The true default-range selectors (Olympic trials, post-victory
combat tiers, Mount Farview reward, the game-start Corak monologue, ...) do
NOT reach this engine — default: now shows the generic OP_0B/door sign
title instead of fabricating ticket-check text.
Builds per-town str pointer tables at **A4-$59EE**, **-$599E**, **-$5986**,
-$594E**, **-$58AE**, **-$580E**, **-$57F6** via **-$7DE2` RNG at open
(caption text only).
Cleric spell stock only — temples do not sell sorcerer spells. The sorcerer
price helper **0x1D97A** (reads **A4-$66E2**) is called from the mage guild
path only (0x1E62E, **cmpi #4** @ 0x1E64A). Temple purchase loop calls
**0x1DAC6** only (0x1DEFC, **cmpi #3** @ 0x1DF1A).
| Table (A4−) | Layout | Purpose |
|---|---|---|
$66F6 |
5×4 bytes | Cleric spell id bytes — 3 sold @ 0x1DF1A (menu keys D–F, handler 0x1DAC6) |
$670A |
5×4 bytes | Internal menu key bytes (slot 3 usually 0x80 pad) |
$6738 |
6× BE u16 | Restore Cond/Algn menu costs (keys A/B @ 0x1CB48) — not cleric spell D/E/F prices |
Town index A4-$79F2 (0–4). Fourth byte per town is padding; only slots 0–2
are built into the purchase UI.
Cleric spell gold: decode_gold_encoding(A4-$66F6[town×4+slot]) @ 0x1DAC6
→ stored in A4-$56BE[slot+3]; debited @ 0x1D872. Same bit encoding as
guild ($66CE): low 5 bits = base; bit5 ×10, bit6 ×100, bit7 ×1000.
Cleric id decode: flat_index = raw_byte + CLERIC_FLAT_ADJUST[town][slot] (see
tools/dump_shop_tables.py). Output: shop_tables.json →
temple_spells.cleric_spells_by_town.
| Town | D (slot 0) | E (slot 1) | F (slot 2) |
|---|---|---|---|
| Middlegate | 1-1 Apparition (10) | 1-2 Awaken (10) | 1-6 Power Cure (1000) |
| Atlantium | 8-3 Mass Distortion (20000) | 9-3 Resurrection (50000) | 9-4 Uncurse Item (100000) |
| Tundara | 3-1 Cold Ray (400) | 3-5 Lasting Light (100) | 4-4 Restore Alignment (500) |
| Vulcania | 4-6 Holy Bonus (2000) | 5-5 Remove Condition (3000) | 7-2 Fiery Flail (10000) |
| Sandsobar | 2-2 Heroism (250) | 2-5 Protection From Elements (300) | 2-7 Weaken (200) |
(level-number = manual cleric list; flat_index = spells.dat record 48..95.)
Full acquisition index: 31-spell-sources.md.
Donation cost table **A4-$6742** (BE u16 per town, index **A4-$79F2**
0–4). Debited at **0x1CA3A** → **0x1C9C0** (subtract **roster.$66**
gold). Not loaded from data-hunk **0x25F72** — that u16 cluster has no
read/copy to **-$6742** in ASM.
Tracking byte: **A4-$799E** (global quest bitfield).
On successful donation (0x1D796, after **0x1D90C** gold check passes):
bit_mask = A4-$66B1[ A4-$79F2 ] ; u8 per town 0..4
A4-$799E |= bit_mask
if A4-$799E == 0x1F: ; all 5 temple bits
move.b #$FE, A4-$794C ; auto-Search pending
move.b #$D4, A4-$3F1C ; Fe Farthing in found-item id[0]
clr.b A4-$799E
; blessed-buff block A4-$79AB..-$799F + addq -$5770 — UI/presentation stub
RNG: engine query -$7BB4(100,1); if result > 0x5A → "Today you are blessed!" buff path
Ported: buffer/sentinel writes (townSvcTempleDonate → reward_queued).
Blessed-buff caption path still deferred.
**A4-$66B1**: 5-byte table in the data hunk (same bytes as master copy @
**0x2637C** first five). Donation GP and quest bit are separate from
cleric spell purchase (spell ids @ **A4-$66F6**, costs @ **A4-$6738**
/ menu **0x1D872**).
Donation gold (A4-$6742, BE u16, town 0→4):
| Town | GP |
|---|---|
| Middlegate | 20 |
| Atlantium | 250 |
| Tundara | 40 |
| Vulcania | 120 |
| Sandsobar | 40 |
Quest bit masks (A4-$66B1, OR into -$799E):
| Town | Bit |
|---|---|
| Middlegate | 1 |
| Atlantium | 2 |
| Tundara | 4 |
| Vulcania | 8 |
| Sandsobar | 16 |
Temple menu shows donation amount from **A4-$6742** @ **0x1D556** (display
only; debit uses same table @ **0x1CA3A**).
Pub rumor: str "Donate at all temples" — option E) in tavern; random rumor pool (below), not a dedicated quest opcode.
Middlegate tile: (10,2) facing W (FAQ x,y; event table (2,10)) — event 30:
OP_0B sign 0x14 (Nordon portrait) + OP_0E 0x0A.
Intro (loc 60 str[9], wired in EventTownServices.cpp case 0x0A):
"The humble wizard Nordon asks, "Will you do me a service (y/n)?""
Handler: default-range bin (0x15EDC → category 0x3C / loc 60). Older docs
misnamed -$7DAC→0xD634 as Nordon; ASM shows that thunk is selector 0x7F
(combat encounter setup). Ported: Y → quest brief or goblet turn-in (XP + Eagle
Eye + found-item gold). Nordonna gate / quest flags still deferred.
Goblet pickup: Middlegate Cavern loc 17 (7,0) → item 224 / 0xE0. Return to
Nordon, not Feldecarb Fountain. Full chain: 53-nordon-nordonna-quests.md.
Middlegate tile: (15,15) (event 17: OP_0E 0x0E only — default-range
dispatch into loc 60 string bank). Not (10,2) and not selector 0x0A.
Prompt (loc 60 str[23], wired in EventTownServices.cpp when sel == 0x0E):
"Fanciful Feldecarb Fountain flows full as fluttering faeries frolic
fastidiously. Flick a farthing (y/n)?"
Failure text (str[25]): "Fool, you have no farthing to flick!"
Known gap: farthing inventory check + castle-key reward (str[24]) not yet ASM-traced — intro y/n only, deferred transaction. Late-game step after Nordonna's temple-donation hint (loc 60 str[20]).
Do not confuse with: Arena 0x08 @ (2,13) (ticket items 0xD0–0xD3);
(2,1) event 31 = OP_0E 0x0B (different selector).
| Mechanism | Field | Rule |
|---|---|---|
| Hireling flag | roster **$0B bit 7** (0x80) |
Set when hiring (0x216E6); clear for normal party members. Murray’s Cave uses **OP_15** op=0x01 val=0x80 → "No hirelings allowed!"
|
| Home town | roster **$0B & 0x7F** |
Values 1–5 (Middlegate…Tundara), written by OP_0E 0x0D enroll (0x1A1CE); inn storage binding only — not the guild-shop gate (see §6.2) |
| Dead / gone | roster **$26 ≥ 0x81** |
**OP_26** party pick skips (0x16C42) |
| Class / attr gate |
**OP_2D** |
Checks **$0C/$0E/$0F** with arg bitmask (class/race/sex), not hireling bit |
Temple member selection uses the same party iterator as other services (-$7F20
slot lookup); hirelings with **$0B.7** still appear unless a script explicitly
filters via **OP_2D** / **OP_15**. Guild spell purchase instead checks
**$79 (class_quest_guild_mask) against the per-town mask (0x1E410, see §6.2) — a **different field** from the 0x0Denroll write above.$79is writable via (1) the generic event-script field selector0x74 (OP_15/18/1F/20, EventFieldMap.h, already ported — some quest event may set it during normal play) or (2) the class-quest reward loop (0x9D76@0x9FE0`, doc 36).
| Step | Selector | Handler | Effect |
|---|---|---|---|
| Open spell shop | 0x05 |
-$7D10 → **0x1E3E6** |
Four sorcerer spells per town; membership required |
| Enroll | 0x0D |
-$7DA0 → **0x1A1B2** / **0x1A1F8**
|
y/n; on success sets roster guild-home byte |
C++ port: game/src/events/TownServiceTransactions.cpp (townSvcMageGuildMember,
townSvcBuySpell) + game/src/events/TownServiceMenu.cpp (townSvcRunMageGuild,
temple's TempleOption::BuySpell), wired from EventTownServices.cpp OP_0E 0x05
and playable via PlayTownServiceUi. Static stock tables live in
EXTRACTED/decomp/mm2_town_tables.{h,c} (mm2_mage_guild_stock,
mm2_temple_spell_stock, mm2_mage_guild_member_mask) mirrored in
tools/mm2_town_tables.py / wiki/maze-walker/{townTables,sessionState,townServices}.js.
OP_0E 0x0D enroll is intentionally not wired to a UI backend yet: its y/n
NPC prompt string was not isolated in str.dat, and per CLAUDE.md ("never
invent... strings") it stays on the faithful sign-intro fallback until found.
An earlier pass mis-attributed the alternate stock builders at **0x1EACC /
0x1EB2E** (tables **A4-$6692 / $669E**, 3 menu slots, keys **9 / : /
;**) to retail **OP_0E 0x05**. That produced the same three spellbook
indices everywhere (decoded as Implosion / Light / Silence) with only per-town
gold varying — wrong.
| Wrong (old) | Correct (ASM + hunk) |
|---|---|
Spell ids @ **A4-$669E** (3× BE u16) global |
**A4-$66E2** — 5×4 bytes, sorcerer flat 0..47, per town
|
Keys **9 / : / ;** |
Keys **A / B / C / D** (sub #$41 @ **0x1E864**) |
| 3 spells |
4 spells (cmpi #4 @ **0x1E64A**) |
| Same spells all towns | Different four spells per town (school progression by town tier) |
Machine-readable stock: shop_tables.json →
static_by_town.mage_guild_spells (regenerate:
python tools/dump_shop_tables.py).
After **-$7FBC** y/n (0xFFFF mode @ **0x1A1A6**), success path **0x1A1B2**
loops active party slots:
- Index:
**A4-$796A[slot]**×**$82**→ roster base**A4-$2A33** - Write:
**roster.$0B ← A4-$79F2 + 1**(1=Middlegate … 5=Sandsobar) @**0x1A1CE** - Then
**0x1A1F8**(guild UI cleanup; restores map index)
Purchase gate @ **0x1E410**: roster **$79** (class flags) AND **A4-$66A9[town]**
must be non-zero — else "Sorry, you must be a member of this guild…" (str.dat
~369).
Flow: **0x160C2** → **-$7D10** → **0x1E3E6** → **0x1E8B0** (RNG guild
name pointers from **A4-$5732…$5714**, **-$7DE2**) → stock build
**0x1E618–0x1E650** calling **0x1D97A** per slot.
| Data | Layout | Use |
|---|---|---|
**A4-$66E2** |
5×4 u8 | Sorcerer **spells.dat flat index** (0..47) per town×slot |
**A4-$6698** |
5×4 BE u16 | Legacy/alternate UI only (0x1EB2E); not retail guild menu @ 0x1D97A
|
**A4-$6686** |
4 BE u16 | Legacy slot addends for alternate UI path |
**A4-$66CE** |
5×4 u8 |
Retail cost-encoding bytes for **0x1D97A** → **A4-$56BE**
|
Gold (retail): decode_gold_encoding(66CE[town×4+slot]) — low 5 bits = base; bit5 ×10,
bit6 ×100, bit7 ×1000. Grant: **0x1D872** sets spellbook bit from the **66E2** byte (not
cleric **66F6**). No cleric spells at guild.
**str.dat hall intros** (11-str-decoded.txt ~328–342), one per town tone:
| Town | Intro flavour |
|---|---|
| Middlegate | Archmage |
| Atlantium | Cabalist |
| Tundara | Mystics by the fire |
| Vulcania | Magicians |
| Sandsobar | Sorcerers / phials |
| Town | A | B | C | D |
|---|---|---|---|---|
| Middlegate | S1/1 Awaken (10) | S1/3 Energy Blast (1000) | S1/7 Sleep (50) | S2/3 Identify Monster (100) |
| Atlantium | S8/2 Mega Volts (50000) | S8/3 Meteor Shower (50000) | S9/1 Implosion (100000) | S9/2 Inferno (100000) |
| Tundara | S4/2 Feeble Mind (600) | S4/3 Fire Ball (2000) | S5/1 Disrupt (3000) | S5/3 Sand Storm (3000) |
| Vulcania | S6/1 Disintegration (5000) | S6/3 Fantastic Freeze (5000) | S6/5 Super Shock (5000) | S7/2 Duplication (25000) |
| Sandsobar | S2/7 Protection from Magic (400) | S3/1 Acid Stream (200) | S3/4 Lightning Bolt (1000) | S4/1 Cold Beam (500) |
Spell acquisition index: 31-spell-sources.md (regenerate
guild tables: python tools/build_spell_sources_doc.py).
Other engine paths still reference **A4-$6692 / $6698 / $669E** with slot-only
indexing and/or **slot×3+9** keys (**0x1EACC**, **0x1EB2E**, **0x1EA66**, …).
Do not use these for per-town guild shop lists.
These are **OP_0E default-path** selectors (category **0x3D, index = selector −
**0x10** → 13..21), used on Olympic Trial / skill tiles in Atlantium — not
the mage-guild spell shop (**0x05). They route through **0x15EDC → -$7DFA**
with a temporary category byte in **A4-$79F2**, separate from **0x1E3E6**.
Corrected 2026-07: -$7DFA is event_dat_loader (0x092F2), NOT the Arena
Games engine (0x9D76, §4.2.2, reached only via explicit selector 0x08) — see
the correction in §1.2. What these Olympic Trial tiles actually run via the
loader reinvocation is not yet reverse-engineered.
Handler **0x1A132** → rumor branch uses:
-
**A4-$119A**(-$EE66): bytecode stream (opcodes + string indices), walked by**0x97FC–0x9932**. -
**A4-$1110**: rumor cursor;**A4-$71D6**: secondary cursor;**A4-$71DB**: last opcode; compared against**A4-$1116**(day/era gate). - Random
**-$7BB4(1, n)**for which rumor variant fires.
Town binding: stream is not indexed by map directly in the walker; town-
specific rumors are authored in the $119A script for each pub event
(different event segments loc 0–4). Pub NPC name strings (Amber, Rowena, …)
are fixed in **str.dat**; E) pulls from the shared rumor table with
RNG + day gates.
Embedded quest hints in rumor str pool (examples from 11-str-decoded.txt):
Donate at all temples, Meal B, then C1 2,10, Hirelings at 15,10, etc.
-
Blacksmith menu B) →
**0x1C25A(1..6)** → reads pre-rolled**A4-$5A56**entries (6 per town, built at shop open from**-$7DE2**RNG). -
General store (
0x07) / default range: inventory from category-specific tables inside**-$7DFA** (event_dat_loaderreinvocation, §4.2); refreshed on each**OP_0E**entry (no separate save flag found in ASM skim). Explicit selector0x08is the Arena Games ticket engine (§4.2.2), not a shop. -
Sandsobar drunk guild recruit (ev 26):
**OP_24200** gp — separate from smith specials.
Data hunk extract: EXTRACTED/ghidra/mm2_data_00.bin (see
python tools/extract_segments.py mm2 -o EXTRACTED/ghidra). Per-town item id
lists for smith specials / pub food are still RNG + str pointers at
shop-open — not static bytes in the data hunk (structure above is ASM-confirmed).
Entry: **OP_0E 0x04** → **-$7D16** (0x8050 menu). Training mode
(stat vs HP) comes from the calendar table **A4-$55BA**, indexed by month/day
@ **0x9B48** → **-$2(a5)**: **1** = stat path, **2** = HP path @
**0x9BCA**. Outdoor/special gate @ **0x9B80**.
Shop menu (-$7D16): **-$7F68** keys **0x31** / **0x32** only —
**0x31** → stat gold path (**0x7F68**), **0x32** → redistribute current HP
(**0x7FF8**, sums party **$5c**). This is separate from temple/inn **-$7EB4**
menus that also use **0x31–0x33**.
**A4-$79F2** (map screen 0–4) is not the multiplier used for training
cost or HP-per-level. That byte follows location order in the game data:
A4-$79F2 |
Town |
|---|---|
| 0 | Middlegate |
| 1 | Atlantium |
| 2 | Tundara |
| 3 | Vulcania |
| 4 | Sandsobar |
Training toughness (weakest → strongest), from
[Might and Magic FAQ.txt](Might%20and%20Magic%20FAQ.txt) §3-6: Middlegate →
Sandsobar → Tundara → Vulcania → Atlantium. Uses a separate training town
index (FAQ: 1, 2, 2, 4, 5 — no index 3):
| Training index | Town(s) | Notes |
|---|---|---|
| 1 | Middlegate | Cheapest; least HP per level |
| 2 | Sandsobar, Tundara | Same multiplier in FAQ |
| 4 | Vulcania | |
| 5 | Atlantium | ~2× Middlegate HP per level (FAQ) |
Map index → training index: **[1, 5, 2, 4, 2]** (map 0..4 as above).
Training gold (FAQ): current_level × training_town_index × 50 gp.
Stat training (0x1C898): **A4-$6720 / A4-$671A** indexed by map
**A4-$79F2**, not training index. Rolled byte += addend; dual compare @
**0x1C8A8/0x1C8BA**; stat id **0–6** → roster **$6B–$72** @
**0x1C7F0**.
| Town (map index) | Stat + | Cap |
|---|---|---|
| Middlegate (0) | 5 | 100 |
| Atlantium (1) | 20 | 100 |
| Tundara (2) | 10 | 100 |
| Vulcania (3) | 10 | 100 |
| Sandsobar (4) | 3 | 50 |
**0x9BCA** does not read **A4-$6714** (temple donation ×100 @
**0x1DC26** / **0x1DC84**).
base = roster[slot].$6B
if party_count > 1: base += one other roster[slot].$6B
RNG (0x6D, 0xA) -> divu #10 -> add to base
if base >= byte_at(A4-$5608): hp_gain_count += 1 ; cmp.b @ 0x9C52
second RNG @ 0x9C54 -> -$7D22 / -$7D28 -> roster.$25 / $5c
Per-level HP also depends on class, endurance, and training town index when leveling (FAQ §2-5, §4-2: ~6 HP/level lost by deferring Atlantium).
7 × **(u8 cost, u8 gain, u16 pad)** — retail 100 gp; gains 50…116.
First five gains match difficulty M, S, T, V, A (not map order). Runtime copy
**A4-$56E**. Not **A4-$6720**.
| Tier (M→S→T→V→A + extras) | Cost | Gain |
|---|---|---|
| 0–4 towns | 100 | 50, 55, 60, 74, 86 |
| 5–6 extended | 100 | 100, 116 |
Tiles (FAQ §8): Middlegate (9,7), Sandsobar (2,7), Tundara (11,7),
Vulcania (5,3), Atlantium (9,4). **OP_24** gates in **event.dat** (typical
200 gp).
| Asset | Location |
|---|---|
| Event opcodes + selectors |
tools/decode_event.py (OPCODES, SELECTOR_NAMES) |
| C event ops mirror | editor/src/core/EventOps.h |
| Roster layout |
editor/src/core/RosterFile, EXTRACTED/docs/06-roster-format.md
|
| Game-state offsets |
tools/mm2_gamestate.py, EXTRACTED/decomp/mm2_gamestate.h
|
| str codec | tools/mm2_codec.py |
| Items |
editor/src/core/ItemsFile, tools/decode_items.py
|
| Spells |
editor/src/core/SpellsFile, tools/decode_spells.py
|
| Shop table dump | tools/dump_shop_tables.py → EXTRACTED/shop_tables.json |
| Topic | Status |
|---|---|
OP_0E selector → handler map |
Complete (ASM) |
| Per-town service tiles |
Complete — §2 matrix (all five towns, byte-exact from event.dat) |
Tundara **OP_0E** wiring |
Complete in current decode (old "missing" note was stale) |
**PlayTownServiceUi** (C++ game) |
Partial — temple/training/smith/guild/tavern menus; intro y/n on pub/temple only (§1.4.3) |
| Wiki maze-walker services | Partial — same intro split; tavern uses fixed-gp simplification (§13.3.1) |
**str.dat** shop UI strings |
Complete (11-str-decoded.txt) |
| Blacksmith item ids per town |
Complete — static 5×6 matrices @ $68EE…$6876
|
| Blacksmith buy transaction |
PORTED — townSvcSmithBuy (0x1BE44/0x1BF16/0x1BDD6), tested (§4.1.1) |
| Blacksmith sell/identify |
Deferred — 0x1BC26 / 0x1B6E0 (presentation-heavy) |
| Store/Special buy |
Engine-gated — store -$7DB8→0xA62C traced; buy loop not ported (§4.2.1) |
| Default-range Arena Games | Ticket gate + monster-type/gold tables PORTED; combat reward deferred (§4.2.2) |
| Tavern food/drink/rumor | Partial — menu shell + fixed gp in port/walker; ROM RNG/VM leaves deferred (§13.3.1) |
| Temple donation gold |
Complete — **A4-$6742** BE u16 ×5 (not **0x25F72**) |
| Temple donation bitfield |
Complete — **A4-$66B1** → **-$799E**, **0x1F** reward |
| Temple spell id bytes |
Complete — $66F6 cleric ×3/town only (no sorcerer @ temple) |
| Temple spell buy transaction |
PORTED — townSvcBuySpell (0x1D872), shared with guild, tested |
| Farthing quest item + flick |
Partial — gate strings + 0x0A selector; Gold Goblet = item 224
|
| Guild enroll write ($0B) |
Complete (roster $0B, 0x1A1CE) — NOT the purchase gate, see below |
| Guild membership gate ($79) |
Complete (read-side) — 0x1E410 vs $66A9 mask. Write paths: generic event field selector 0x74 (ported, EventFieldMap.h) or the unported/buggy 0x9D76@0x9FE0 reward loop (doc 36) |
| Guild spell tables |
Complete — $66E2 ids 5×4, $6698 costs 5×4, $6686 addends ×4 |
| Guild spell buy transaction |
PORTED — townSvcBuySpell + townSvcRunMageGuild menu driver, tested |
| Pub rumor mechanism |
Complete (stream $119A + RNG) |
| Pub food costs + menu text |
Complete — $6760 BE u16 + str block per town |
| Pub meal item ids |
N/A — roster **$78** encoding, not items.dat
|
| Rumor index → town | Partial (NPC names fixed; rumor ids per loc in event segments) |
| Training cost / town index | Complete (FAQ + ASM stat path); map ≠ training index |
Training HP path @ 0x9BCA
|
Partial — might-sum + RNG; no 6714; town index in FAQ only |
| Training stat tables |
Complete — **A4-$6720 / A4-$671A** @ 0x1C898 (map index) |
| Today's specials item sets |
Complete — static $683A + date index $681C/$6816
|
| Event → text routing |
Complete — [30-event-to-string-path.md](Event-to-String-Path)
|
Overall static shop table completeness (town 0–4): ~92%. RNG at shop open affects str.dat captions only (smith/temple/guild names); numeric stock (item ids, spell indices, food GP) is in the data hunk.
Regenerate: python tools/dump_shop_tables.py --json EXTRACTED/shop_tables.json
| Table | Location | Values (town 0→4) |
|---|---|---|
| Feeding-frenzy GP |
**A4-$6742** (tavern A @ 0x1CA2E) |
20, 250, 40, 120, 40 |
| Temple quest bit |
**A4-$66B1** (= master **0x2637C** first 5) |
1, 2, 4, 8, 16 |
| Training stat + |
**A4-$6720** (by map 0→4) |
5, 20, 10, 10, 3 |
| Training stat cap |
**A4-$671A** (by map 0→4) |
100, 100, 100, 100, 50 |
| Training town index | FAQ §3-6 (not map order) | 1, 5, 2, 4, 2 |
| Temple cost scale |
**A4-$6714** (by map 0→4) |
1, 5, 2, 3, 2 → donate GP 100…500 |
Training cost: level × training_town_index × 50. Indices:
1 Middlegate, 2 Sandsobar & Tundara, 4 Vulcania, 5 Atlantium.
Healing cost (healthy): level × A4-$6714 × 10 (temple scale; Vulcania=3, not training index 4).
- ×10 multiplier when character is dead:
level × index × 100 - ×100 multiplier when eradicated:
level × index × 1,000 - Go to Middlegate (index 1) for cheapest healing. [FAQ §3-6]
Portal table: see [34-commerce-formulas.md §3](Commerce-Formulas) — five portals linking all towns, costs 10–100 gp per leg.
Day-based shop bonus cycle: see [34-commerce-formulas.md §2](Commerce-Formulas) — ~30-day cycle; best bonus on days 30/60/90/120/150/180 (+5/+6/+7/+8/+9/+12). Calendar day at A4-$79DE; era at A4-$79B6.
HP path @ 0x9BCA (calendar mode **2**): might-sum + **divu #10** RNG addend;
**cmp.b** vs **A4-$5608** @ **0x9C52**. Does not read **A4-$6714**.
Stat training @ 0x1C898: add **A4-$6720[map]**; cap **A4-$671A[map]**
(**A4-$79F2**, not training index).
Tier template @ 0x26578: 7 × **(u8 cost, u8 gain, u16 pad)** — retail
100 gp / gains 50…116; first five gains match difficulty order
M, S, T, V, A.
**A4-$6714:** five BE **u16** after stat-cap bytes — used @ **0x1DC26** as
temple donation multiplier × 100, not HP training.
Not training stat addends: data hunk **0x26800** (24 bytes) — unrelated to
**A4-$6720**.
-
Not inn: food/drinks =
**OP_0E0x01**, handler**0x1A132**. Inn**0x02**→**-$7CD4**(rest/dismiss only). -
Fixed per town: gold costs in data hunk
**A4-$6760, indexed**town×6 + menu×2**, BE u16 @**0x1CEA4. Menu labels A/B/C map to fixed**str.dat**text blocks (seefood_menu_textinshop_tables.json).
| Town | A (gp) | B (gp) | C (gp) |
|---|---|---|---|
| Middlegate | 10 | 50 | 100 |
| Atlantium | 1000 | 2000 | 3000 |
| Tundara | 200 | 100 | 1000 |
| Vulcania | 5000 | 500 | 1000 |
| Sandsobar | 20 | 50 | 250 |
-
Meal mechanics: tier table
**A4-$6B08, cost addends**A4-$6B1A**(these are not item ids — prior misread mapped to Staff/Blowpipe). Handler**0x18EC0computes gold; purchase stores encoding on roster**$78**. -
Drinks: six global entries — base index
**A4-$6AF0**, addend**A4-$6AED**resolved via**0x18F78**/**-$7BB4**. Same drink names all towns (str.datlines 209–214).
Finer ASM tracing shows the pub cost/effect leaves are not deterministic gp
deducts in the ROM handler; the remake and wiki walker use simplified fixed
prices from A4-$6760 / global drink table until the RNG paths are traced.
-
OP_0E 0x03→ static handler0x1D208(tavern / food shell). The0x1A132address is the inn handler (0x01), not the pub. - Food/drink submenus also enter via selectors
0xC9/0xCA→**0x1980A**and route through the runtime vtable thunk**-$7D40**(no static target). - Cost leaves
**0x18EC0**(food) /**0x18F78**(drinks) are RNG-gated (-$7BB4): they produce a tier/addend encoding byte (foodA4-$6B08/A4-$6B1A; drinkA4-$6AF0/A4-$6AED) written to roster**+$78**(+ a+$7Cmode bit) by**0x019030**— the feeding-frenzy path does not deduct gp. - The gp food table
**A4-$6760**(10/50/100…) is consumed by**0x1CEA4**+**0x1C9C0**from a temple-style food UI (**0x1CAC4**); whether the pub reuses it is unconfirmed (this doc attributes it to the pub; the only static caller found is in the temple region). Not ported to avoid fabrication. - Drink stat bonuses (doc 34 §5: Orc Beer +5 Might, etc.) are applied by unported
VM opcode handlers via success counters
A4-$79A6–$79AB; the sick/success roll is**0x19D64**(RNG(50)==2) / stat-reset**0x19B28**. - Rumors
**0x97FC**walk per-location event bytecodeA4-$119A(cursorsA4-$1110/$71D6/$71DB, day gateA4-$1116) — display-only, no static hook from the pub menu. The food/drink key loop**0x19962**only accepts A–D (purchase A–C + tip D); E) rumors is authored in per-location event bytecode, not this key handler. - Drinks minigame (patron Y/N, gold pool, sick roll) is a separate leaf
**0x19E20**(0x19AD6/0x19D64/0x19B28), not the0x1980AA–D loop.
The remake shows the faithful str.dat NPC intro + y/n for pub/temple, then opens
the pluggable A–E menu when a backend is bound (§1.4.3). Food/drink effects
(RNG tiers, roster +$78 encoding, sick roll) remain deferred — no invented
stat bonuses in the C++ port.
| Service | Static in data hunk | Runtime RNG |
|---|---|---|
| Smith weapons/armor/misc/specials |
Yes — $68EE…$6876, 6×/town/cat |
$5ACE / $5AA6 / $5A56 str captions @ 0x1C54A
|
| Smith Today's Specials |
Yes — $683A ids + $681C/$6816 date index |
Specials caption ptrs $5A56
|
| Temple cleric (×3) |
Yes — $66F6 + CLERIC_FLAT_ADJUST decode |
$59EE…$57F6 name ptrs @ 0x1D208
|
| Guild sorcerer (×4) |
Yes — $66E2 per town; $6698/$6686 costs |
$576E / $56F6 name ptrs @ 0x1E8B0
|
Full dumps: python tools/dump_shop_tables.py → EXTRACTED/shop_tables.json
(static_by_town + random_pool).
Sparse BE u16 cluster includes 10, 50, 100, 1000, 2000 (travel, training gates,
skills). Per-tile amounts still live in **event.dat** (OP_24 argc).
Pub meals are not items.dat SKUs (they encode on roster **$78**). This table
lists inventory quest items, consumables, and travel food — do not confuse
with tavern food menus (§13 / static_by_town.pub).
| Ref | Category | Event / usage |
|---|---|---|
| 224 | dungeon_treasure |
Gold Goblet — Middlegate Cavern loc 17 (7,0) (OP_19 → 0xE0). Return to Nordon (10,2), not the fountain. See 53-nordon-nordonna-quests.md. |
| 175 | consumable | Magic Meal — Create Food; treasure / Sandsobar smith misc |
| 176 | consumable | Antidote Ale — Cure Poison drink |
| (str) | pub_meal | Devils Food Brownie — Atlantium pub meal C only (roster $78) |
roster **$25** |
party_food | Travel food supply; **OP_15** oasis +40 |
**OP_28** |
inventory | Consume item in scripts (e.g. 0x10 + count) |
Machine-readable: quest_items in EXTRACTED/shop_tables.json.
Olympic Trial (Atlantium (4,8), event str "Hurl the spear…") is not
training **0x04. It uses **OP_02** + **OP_24 500 + **OP_15/OP_20**
on roster **$6D** (personality base) and **party_effect** to grant Athlete
skill — separate from **-$7D16** HP training.
| Page | Why |
|---|---|
| Spell Sources | Where temple/guild spells come from |
| Event to String Path | OP_0E vs event strings |
| Event Script Opcodes | OP_0E / OP_0B / OP_24 |
| Embedded Exe Strings | Exe-embedded shop prompts |
- Docs Wiki Hub ← full doc index
- Getting Started ← read this first
- Overview
- Workspace Notes
- Open Questions
- Full Analysis
- Game Remake
- Startup and Init
- Runtime Memory Map
- Main Loop and Map
- Exploration Input and Options
- Party and Session
- Game State Struct
- Data Hunk mm2 data 00
- Time Era Calendar
- GFX Loading
- ANM TV Format
- PC DOS graphics
- SNES graphics
- 3D View and Game Screen
- Amiga 3D Render Process
- Scripted Scene Graphics
- Event Graphics Opcodes
- Title Screen Assets
- Title Screen Animation
- Format inventory
- items.dat
- monsters.dat
- roster.dat
- attrib.dat
- map.dat
- spells & item use
- event.dat
- Events by location ← 71 maps
- Events hub (numbered)
- Event Script Opcodes
-
Event Script DSL ←
.mm2evtauthoring - Event Text Rendering
- Event Graphics Opcodes
- Combat Overview
- Combat System
- Encounter Tables
- Spell Cast ASM
- monsters.dat abilities
- Spells and item use
- Amiga audio in exe ← Paula / DATA / remake
- Controls: Sounds / Walk Beep
- MM2 music format (superseded)
- Title/death audio paths (superseded)
- Known songs catalog (superseded)
- Town Services
- Spell Sources
- Character Mechanics
- Skills and Hirelings
- Commerce Formulas
- Commerce World Services
- Mount Farview Class Quest
- Class Quest HP Bug
- Event Runtime
- Event to String Path
- Embedded Exe Strings
- Copy Protection
- Time Era Calendar
- Game State Struct
- MM1 Overview ← hub + decode status
- MM1 MAZEDATA format
- MM1 to MM2 outdoor
- MM1 WALLPIX by sector
- MM1 art & graphics
- MM1 items / monsters (status)
- MM1 map walker ↗
- MM1 2D maps gallery
- MM1 WALLPIX gallery