78,000 words of grounded, citation-backed research across 13 major AC
subsystems, produced by 13 parallel Opus-4.7 high-effort agents. Plus
compact C# port scaffolds for the top-5 systems and a phase-E-through-H
roadmap update sequencing the work.
Research (docs/research/deepdives/):
- 00-master-synthesis.md (navigation hub + dependency graph)
- r01-spell-system.md 5.4K words (fizzle sigmoid, 8 tabs, 0x004A wire)
- r02-combat-system.md 5.9K words (damage formula, crit, body table)
- r03-motion-animation.md 8.2K words (450+ commands, 27 hook types)
- r04-vfx-particles.md 5.8K words (13 ParticleType, PhysicsScript)
- r05-audio-sound.md 5.6K words (DirectSound 8, CPU falloff)
- r06-items-inventory.md 7.4K words (ItemType flags, EquipMask 31 slots)
- r07-character-creation.md 6.3K words (CharGen dat, 13 heritages)
- r08-network-protocol-atlas 9.7K words (63+149+94 opcodes mapped)
- r09-dungeon-portal-space.md 6.3K words (EnvCell, PlayerTeleport flow)
- r10-quest-dialogs.md 7.1K words (emote-script VM, 122 actions)
- r11-allegiance.md 5.4K words (tree + XP passup + 5 channels)
- r12-weather-daynight.md 4.5K words (deterministic client-side)
- r13-dynamic-lighting.md 4.9K words (8-light cap, hard Range cutoff)
Every claim cites a FUN_ address, ACE file path, DatReaderWriter type,
or holtburger/ACViewer reference. The master synthesis ties them into a
dependency graph and phase sequence.
Key architectural finding: of 94 GameEvents in the 0xF7B0 envelope,
ZERO are handled today — that's the largest network-protocol gap and
blocks F.2 (items) + F.5 (panels) + H.1 (chat).
C# scaffolds (src/AcDream.Core/):
- Items/ItemInstance.cs — ItemType/EquipMask enums, ItemInstance,
Container, PropertyBundle, BurdenMath
- Spells/SpellModel.cs — SpellDatEntry, SpellComponentEntry,
SpellCastStateMachine, ActiveBuff,
SpellMath (fizzle sigmoid + mana cost)
- Combat/CombatModel.cs — CombatMode/AttackType/DamageType/BodyPart,
DamageEvent record, CombatMath (hit-chance
sigmoids, power/accuracy mods, damage formula),
ArmorBuild
- Audio/AudioModel.cs — SoundId enum, SoundEntry, WaveData,
IAudioEngine / ISoundCache contracts,
AudioFalloff (inverse-square)
- Vfx/VfxModel.cs — 13 ParticleType integrators, EmitterDesc,
PhysicsScript + hooks, Particle struct,
ParticleEmitter, IParticleSystem contract
All Core-layer data models; platform-backed engines live in AcDream.App.
Compiles clean; 470 tests still pass.
Roadmap (docs/plans/2026-04-11-roadmap.md):
- Phase E — "Feel alive": motion-hooks + audio + VFX
- Phase F — Fight + cast + gear: GameEvent dispatch, inventory,
combat, spell, core panels
- Phase G — World systems: sky/weather, dynamic lighting, dungeons
- Phase H — Social + progression: chat, allegiance, quests, char creation
- Phase J — Long-tail (renumbered from old Phase E)
Quick-lookup table updated with 10+ new rows mapping observations to
new phase letters.
1080 lines
56 KiB
Markdown
1080 lines
56 KiB
Markdown
# R6 — Items, Inventory Containers, and the Property Model
|
||
|
||
**Status:** Deep-dive research; no code written.
|
||
**Audience:** acdream client implementation (Phase R6 — item model + paperdoll UI).
|
||
**Sources:**
|
||
- Decompiled retail `acclient.exe` — `docs/research/decompiled/chunk_004A0000.c` (paperdoll), `chunk_00570000.c` (WError dispatcher), `chunk_006B0000.c` (chat/skill parser).
|
||
- `references/ACE/Source/ACE.Entity/Enum/` — `ItemType.cs`, `EquipMask.cs`, `ObjectDescriptionFlag.cs`, `WeenieHeaderFlags.cs`, `WeenieType.cs`, `MaterialType.cs`, `IdentifyResponseFlags.cs`, `ContainerType.cs`, `ItemXpStyle.cs`.
|
||
- `references/ACE/Source/ACE.Entity/Enum/Properties/` — `PropertyType.cs`, `PropertyInt.cs` (811 lines), `PropertyBool.cs`, `PropertyFloat.cs`, `PropertyString.cs`, `PropertyDataId.cs`, `PropertyInstanceId.cs`, `PropertyInt64.cs`.
|
||
- `references/ACE/Source/ACE.Server/WorldObjects/` — `Container.cs` (1013 lines), `Player_Inventory.cs` (3983 lines).
|
||
- `references/ACE/Source/ACE.Server/Physics/Common/EncumbranceSystem.cs` — burden math.
|
||
- `references/ACE/Source/ACE.Server/Network/GameMessages/GameMessageOpcode.cs`, `GameAction/GameActionType.cs`, `GameEvent/GameEventType.cs`.
|
||
- `references/ACE/Source/ACE.Server/Network/Structure/AppraiseInfo.cs` (866 lines) — canonical identify-response serialization.
|
||
- `references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Networking.cs` — `SerializeCreateObject`, `SerializeModelData`, `SerializePhysicsData`.
|
||
- `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/PublicWeenieDesc.generated.cs` — byte-for-byte wire struct, flag-gated fields.
|
||
- `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/C2S/Actions/Inventory_*.generated.cs`, `Item_Appraise.generated.cs`.
|
||
- `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/S2C/Events/Item_SetAppraiseInfo.generated.cs`, `Item_ServerSays{MoveItem,ContainId}.generated.cs`, `Item_WearItem.generated.cs`.
|
||
- `references/holtburger/apps/holtburger-cli/src/pages/game/domains/inventory.rs` — working client state machine.
|
||
|
||
The decompiled client is ground truth for **what the client stores and displays**; ACE is ground truth for **what the server sends and expects**; Chorizite is the canonical wire-level byte schema.
|
||
|
||
---
|
||
|
||
## 1. ItemType enum
|
||
|
||
The retail `ItemType` is a **32-bit bitfield** (not a pure enum) — a single item can have exactly one primary type bit, but server-side matching code ORs multiple bits to create composite categories ("VendorShopKeep", "RedirectableItemEnchantmentTarget", etc.). It is transmitted in `PublicWeenieDesc.Type` as `uint32` and is also stored as `PropertyInt.ItemType` (id 1).
|
||
|
||
From `ACE.Entity.Enum.ItemType`, every bit value the retail client understands:
|
||
|
||
| Bit | Value | Name | Notes |
|
||
|---|---|---|---|
|
||
| -- | `0x00000000` | None | Ephemeral objects (creatures in combat mode) default here |
|
||
| 0 | `0x00000001` | MeleeWeapon | swords, maces, axes, unarmed |
|
||
| 1 | `0x00000002` | Armor | armored clothing (chest, arms, legs, gauntlets with AL) |
|
||
| 2 | `0x00000004` | Clothing | non-armored (robes, shirts, breech, pants, shoes w/ 0 AL) |
|
||
| 3 | `0x00000008` | Jewelry | necklaces, bracelets, rings, trinkets |
|
||
| 4 | `0x00000010` | Creature | NPCs, monsters, player characters |
|
||
| 5 | `0x00000020` | Food | comestibles (chips, eats, drinks) |
|
||
| 6 | `0x00000040` | Money | pyreals (coin stack) |
|
||
| 7 | `0x00000080` | Misc | catchall — quest items, loot tokens, decorations |
|
||
| 8 | `0x00000100` | MissileWeapon | bows, crossbows, atlatls, thrown |
|
||
| 9 | `0x00000200` | Container | packs, chests, sacks (WeenieType.Container) |
|
||
| 10 | `0x00000400` | Useless | salvage bundles, junk |
|
||
| 11 | `0x00000800` | Gem | raw gems, cut gems |
|
||
| 12 | `0x00001000` | SpellComponents | taper, ivory scarab, cobalt jungle, etc |
|
||
| 13 | `0x00002000` | Writable | books, inscribable notes |
|
||
| 14 | `0x00004000` | Key | keys, keyrings |
|
||
| 15 | `0x00008000` | Caster | wands/orbs/staves casting a spell |
|
||
| 16 | `0x00010000` | Portal | portal stones, linked portals |
|
||
| 17 | `0x00020000` | Lockable | lockboxes, locked doors |
|
||
| 18 | `0x00040000` | PromissoryNote | MMD (mugs), trade notes |
|
||
| 19 | `0x00080000` | ManaStone | mana charge stones |
|
||
| 20 | `0x00100000` | Service | trainer services, vendor services |
|
||
| 21 | `0x00200000` | MagicWieldable | wand/orb/staff wieldable subtype |
|
||
| 22 | `0x00400000` | CraftCookingBase | raw cooking ingredient |
|
||
| 23 | `0x00800000` | CraftAlchemyBase | raw alchemy ingredient |
|
||
| 25 | `0x02000000` | CraftFletchingBase | raw fletching material (feathers/shafts) |
|
||
| 26 | `0x04000000` | CraftAlchemyIntermediate | alchemical solvents/elixirs in progress |
|
||
| 27 | `0x08000000` | CraftFletchingIntermediate | half-built arrows |
|
||
| 28 | `0x10000000` | LifeStone | recall lifestones |
|
||
| 29 | `0x20000000` | TinkeringTool | leather kits, oil kits, etc |
|
||
| 30 | `0x40000000` | TinkeringMaterial | refined salvage bundles |
|
||
| 31 | `0x80000000` | Gameboard | chess, chess pieces |
|
||
|
||
Bits 24 (0x01000000) is reserved (gap in ACE). `ItemType` in bit `PropertyInt.TargetType` is also a `PublicWeenieDesc.TargetType` field in the wire header — gated by `WeenieHeaderFlag.TargetType = 0x00080000`.
|
||
|
||
---
|
||
|
||
## 2. EquipMask — paperdoll slots
|
||
|
||
The **paperdoll** is the character-window UI with fixed slots; each slot corresponds to a single bit in a 32-bit `EquipMask`. An item's eligible slots are `PropertyInt.ValidLocations` (id 9). The slot the item is **currently equipped in** is `PropertyInt.CurrentWieldedLocation` (id 10). Both are written to the wire as `uint32`, gated by `WeenieHeaderFlag.ValidLocations = 0x00010000` and `CurrentlyWieldedLocation = 0x00020000`.
|
||
|
||
From `ACE.Entity.Enum.EquipMask` — this data is sent as `loc` in the player description message `F7B0 -0013`:
|
||
|
||
| Bit | Value | Name | Paperdoll slot |
|
||
|---|---|---|---|
|
||
| 0 | `0x00000001` | HeadWear | Head (hat / helmet underlay) |
|
||
| 1 | `0x00000002` | ChestWear | Shirt / robe chest layer |
|
||
| 2 | `0x00000004` | AbdomenWear | Breech / robe abdomen |
|
||
| 3 | `0x00000008` | UpperArmWear | Shirt upper-arm |
|
||
| 4 | `0x00000010` | LowerArmWear | Shirt lower-arm |
|
||
| 5 | `0x00000020` | HandWear | Gloves / clothing |
|
||
| 6 | `0x00000040` | UpperLegWear | Pants upper-leg |
|
||
| 7 | `0x00000080` | LowerLegWear | Pants lower-leg |
|
||
| 8 | `0x00000100` | FootWear | Shoes |
|
||
| 9 | `0x00000200` | ChestArmor | Cuirass / plate |
|
||
| 10 | `0x00000400` | AbdomenArmor | Abdomen armor plate |
|
||
| 11 | `0x00000800` | UpperArmArmor | Pauldron |
|
||
| 12 | `0x00001000` | LowerArmArmor | Vambrace |
|
||
| 13 | `0x00002000` | UpperLegArmor | Tasset |
|
||
| 14 | `0x00004000` | LowerLegArmor | Greave |
|
||
| 15 | `0x00008000` | NeckWear | **Necklace** — paperdoll offset `0x604` |
|
||
| 16 | `0x00010000` | WristWearLeft | **Left bracelet** — offset `0x608` |
|
||
| 17 | `0x00020000` | WristWearRight | **Right bracelet** — offset `0x610` |
|
||
| 18 | `0x00040000` | FingerWearLeft | **Left ring** — offset `0x60c` |
|
||
| 19 | `0x00080000` | FingerWearRight | **Right ring** — offset `0x614` |
|
||
| 20 | `0x00100000` | MeleeWeapon | (item-tag; server matches via Weapon ready slot) |
|
||
| 21 | `0x00200000` | Shield | **Shield** — offset `0x620` |
|
||
| 22 | `0x00400000` | MissileWeapon | (item-tag) |
|
||
| 23 | `0x00800000` | MissileAmmo | **Missile ammo** — offset `0x61c` |
|
||
| 24 | `0x01000000` | Held | **Weapon** ready slot — offset `0x618` |
|
||
| 25 | `0x02000000` | TwoHanded | Two-handed weapon flag |
|
||
| 26 | `0x04000000` | TrinketOne | **Trinket** — offset `0x62c` |
|
||
| 27 | `0x08000000` | Cloak | **Cloak** — offset `0x630` |
|
||
| 28 | `0x10000000` | SigilOne | **Blue Aetheria** sigil — offset `0x634` |
|
||
| 29 | `0x20000000` | SigilTwo | **Yellow Aetheria** sigil — offset `0x638` |
|
||
| 30 | `0x40000000` | SigilThree | **Red Aetheria** sigil — offset `0x63c` |
|
||
| 31 | `0x80000000` | Clothing umbrella | or-mask only |
|
||
|
||
### Cross-verification in the decompiled client
|
||
|
||
From `chunk_004A0000.c` (function `FUN_004a5200`, the paperdoll drop-target dispatcher), fixed offsets off `param_1` (the paperdoll instance) are the per-slot drop-zone widget handles. The mapping below is exact:
|
||
|
||
```
|
||
+0x604 NeckWear "Drag necklaces here to wear them"
|
||
+0x608 WristWearLeft "Drag bracelets here to wear them"
|
||
+0x610 WristWearRight "Drag bracelets here to wear them"
|
||
+0x60c FingerWearLeft "Drag rings here to wear them"
|
||
+0x614 FingerWearRight "Drag rings here to wear them"
|
||
+0x618 Held (weapon) "Drag weapons here to wield them"
|
||
+0x61c MissileAmmo "Drag missile ammunition here to wield it"
|
||
+0x620 Shield "Drag shields here to wield them"
|
||
+0x624, +0x628 Clothing (chest/robes) "Drag clothing items here to wear them"
|
||
+0x62c TrinketOne "Drag trinkets here to activate them"
|
||
+0x630 Cloak "Drag cloaks here to activate them"
|
||
+0x634 SigilOne (Blue) "Drag a Blue Aetheria sigil here to activate it"
|
||
+0x638 SigilTwo (Yellow)"Drag a Yellow Aetheria sigil here to activate it"
|
||
+0x63c SigilThree (Red) "Drag a Red Aetheria sigil here to activate it"
|
||
+0x640 HeadWear "Drag head items here to wear them"
|
||
+0x644 ChestWear "Drag chest items here to wear them"
|
||
+0x648 AbdomenWear "Drag abdomen items here to wear them"
|
||
+0x64c UpperArmWear "Drag upper arm items here to wear them"
|
||
+0x650 LowerArmWear "Drag lower arm items here to wear them"
|
||
+0x654 HandWear "Drag glove items here to wear them"
|
||
+0x658 UpperLegWear "Drag upper leg items here to wear them"
|
||
+0x65c LowerLegWear "Drag lower leg items here to wear them"
|
||
+0x660 FootWear "Drag foot coverings here to wear them"
|
||
```
|
||
|
||
When a `param_3 == 0` (hover/tooltip, no item dropped yet), the drop-hint text is shown. When `param_3 != 0` (an item is hovered over the slot while held), the code flips a boolean `bVar1` — for jewelry/clothing slots it's `true` ("worn" / "take off"), for weapon-family slots it's `false` ("wielded" / "unwield"). Then it formats `"%s (%s)\nDouble-click to %s"` where `%s` is the item name, `worn`/`wielded`, and `take off`/`unwield`. **That's the retail tooltip format — acdream must reproduce it verbatim.**
|
||
|
||
### Slot aggregation sets
|
||
|
||
Multi-bit unions the server uses for validation:
|
||
|
||
- `Armor = ChestArmor | AbdomenArmor | UpperArmArmor | LowerArmArmor | UpperLegArmor | LowerLegArmor | FootWear` — AL-contributing slots.
|
||
- `ArmorExclusive` — `Armor` minus `FootWear` (feet are dual-use).
|
||
- `Jewelry = NeckWear | WristWearLeft | WristWearRight | FingerWearLeft | FingerWearRight | TrinketOne | Cloak | SigilOne | SigilTwo | SigilThree`.
|
||
- `Sigil = SigilOne | SigilTwo | SigilThree` — the three Aetheria slots.
|
||
- `Selectable = MeleeWeapon | Shield | MissileWeapon | Held | TwoHanded` — things that go in the ready-slot "weapon" area.
|
||
- `ReadySlot = Held | TwoHanded | TrinketOne | Cloak | SigilOne | SigilTwo`.
|
||
|
||
---
|
||
|
||
## 3. Property schema — seven tables
|
||
|
||
Retail AC represents every object's state as a bundle of typed property tables, keyed by `ushort` enum ids. This is the **entity-component data model** of the whole game. There are **seven** property tables:
|
||
|
||
| PropertyType | Key type | Value type | ACE enum | Wire tag |
|
||
|---|---|---|---|---|
|
||
| PropertyInt | ushort | int32 | `PropertyInt` (0–390 + custom ≥8000) | `0x0001` IntStatsTable |
|
||
| PropertyInt64 | ushort | int64 | `PropertyInt64` (0–8 + custom) | `0x2000` Int64StatsTable |
|
||
| PropertyBool | ushort | uint32 (as bool) | `PropertyBool` (0–130 + custom) | `0x0002` BoolStatsTable |
|
||
| PropertyFloat | ushort | double (8 bytes) | `PropertyFloat` (0–171 + custom) | `0x0004` FloatStatsTable |
|
||
| PropertyString | ushort | WString16L | `PropertyString` (0–52 + custom) | `0x0008` StringStatsTable |
|
||
| PropertyDataId | ushort | uint32 (DID) | `PropertyDataId` (0–61 + custom) | `0x1000` DidStatsTable |
|
||
| PropertyInstanceId | ushort | uint32 (guid) | `PropertyInstanceId` (0–45 + custom) | (inline per-property, see §4) |
|
||
| PropertyAttribute | ushort | attribute struct | (Strength, Endurance, Coord, Quick, Focus, Self) | sent in PlayerDescription |
|
||
| PropertyAttribute2nd | ushort | vital struct | (Health, Stamina, Mana) | sent in PlayerDescription |
|
||
| PropertyPosition | ushort | 32-byte position | (Location, Sanctuary, LastOutside, ...) | sent in PlayerDescription |
|
||
|
||
Property enum ids are stable across all clients (retail 1.0.5.x through ACE) — they are part of the wire contract.
|
||
|
||
### Item-relevant PropertyInt ids (highest-traffic)
|
||
|
||
```
|
||
1 ItemType // see §1 — bitfield of ItemType
|
||
2 CreatureType // for creatures / slayer matches
|
||
3 PaletteTemplate // recolor palette id
|
||
4 ClothingPriority // CoverageMask — layer order
|
||
5 EncumbranceVal // this item's burden units (int)
|
||
6 ItemsCapacity // if container: main-pack slots
|
||
7 ContainersCapacity // if container: side-pack slots
|
||
9 ValidLocations // EquipMask bitfield of legal slots
|
||
10 CurrentWieldedLocation // actual EquipMask slot (when equipped)
|
||
11 MaxStackSize // hard cap (pyreals 10000, gems 100, arrows 100, etc.)
|
||
12 StackSize // current count
|
||
13 StackUnitEncumbrance // per-unit burden
|
||
14 StackUnitMass // per-unit server-side mass
|
||
15 StackUnitValue // per-unit pyreal value
|
||
16 ItemUseable // Usable enum — "double-click does what"
|
||
17 RareId // rare index, for rare-items display
|
||
18 UiEffects // IconHighlight — magic/enchanted overlay
|
||
19 Value // total pyreal sale value
|
||
20 CoinValue // for Money items: pyreal count (ephemeral on players)
|
||
21 TotalExperience // for XP-awarding containers/items
|
||
27 ArmorType // cloth/chain/scale/plate/leather
|
||
28 ArmorLevel // AL (base)
|
||
83 ActivationResponse // Use action behavior
|
||
91 MaxStructure // max charges/uses
|
||
92 Structure // current charges/uses
|
||
93 PhysicsState // PhysicsState flags
|
||
94 TargetType // ItemType mask of valid cast targets
|
||
95 RadarBlipColor // RadarColor enum
|
||
96 EncumbranceCapacity // your cap in burden units
|
||
99 PkLevelModifier // PK status mod
|
||
105 ItemWorkmanship // 1..10 workmanship
|
||
106 ItemSpellcraft // spellcraft (max spell difficulty)
|
||
107 ItemCurMana // current mana
|
||
108 ItemMaxMana // max mana
|
||
109 ItemDifficulty // arcane lore difficulty
|
||
110 ItemAllegianceRankLimit // min rank to wield
|
||
113 Gender // for creatures; also for heritage-locked items
|
||
114 Attuned // AttunedStatus (boon/bonded/untradeable)
|
||
115 ItemSkillLevelLimit // min skill value to wield
|
||
117 ItemManaCost // mana cost per tick
|
||
131 MaterialType // MaterialType enum (see §11)
|
||
134 PlayerKillerStatus // PK status
|
||
158 WieldRequirements // WieldRequirement enum (skill, level, attribute, etc.)
|
||
159 WieldSkillType // Skill enum for requirement check
|
||
160 WieldDifficulty // threshold for requirement
|
||
166 SlayerCreatureType // slayer damage multiplier target
|
||
170 NumItemsInMaterial // salvage bundle count
|
||
171 NumTimesTinkered // 0..10 tinkers applied
|
||
172 AppraisalLongDescDecoration // long-desc display decoration
|
||
174 AppraisalPages // book pages available to read
|
||
175 AppraisalMaxPages // max pages the book has
|
||
176 AppraisalItemSkill // skill used to appraise
|
||
177 GemCount, 178 GemType // set-bonus source gems
|
||
179 ImbuedEffect // ImbuedEffectType enum (attack mod, crippling, etc.)
|
||
181 ChessRank // chess score (if gameboard)
|
||
257 ItemAttributeLimit // required attribute type
|
||
258 ItemAttributeLevelLimit // required attribute level
|
||
265 EquipmentSetId // armor set id
|
||
267 Lifespan, 268 RemainingLifespan
|
||
270 WieldRequirements2-4 (271/272, 273/274/275, 276/277/278) — stacked wield gates
|
||
279 Unique // unique-cap count
|
||
280 SharedCooldown // cooldown-group id
|
||
303-306 ImbuedEffect2..5 // additional imbue bits
|
||
319 ItemMaxLevel // if item-xp, cap level
|
||
320 ItemXpStyle // Fixed / ScalesWithLevel / FixedPlusBase
|
||
322 AetheriaBitfield // AetheriaBitfield (which sigils)
|
||
```
|
||
|
||
### Item-relevant PropertyBool
|
||
|
||
```
|
||
2 Open // container open state
|
||
3 Locked // lockable state
|
||
22 Inscribable
|
||
23 DestroyOnSell
|
||
38 IsFrozen // attuned / account-bound
|
||
69 IsSellable
|
||
85 AppraisalHasAllowedWielder // set when AllowedWielder IID is set
|
||
91 Retained // does not drop on death
|
||
94 AppraisalHasAllowedActivator
|
||
99 Ivoryable // can apply ivory
|
||
100 Dyable // can be dyed
|
||
108 RareUsesTimer
|
||
116 WieldOnUse // equip on first use
|
||
130 AutowieldLeft
|
||
```
|
||
|
||
### Item-relevant PropertyFloat
|
||
|
||
```
|
||
3 HealthRate 22 DamageVariance 63 DamageMod
|
||
5 ManaRate 29 WeaponDefense 62 WeaponOffense
|
||
6–8 UponResurrection 22 DamageVariance 87 ItemEfficiency
|
||
13–19 ArmorMod vs (Slash, Pierce, Bludgeon, Cold, Fire, Acid, Electric)
|
||
20 CombatSpeed 21 WeaponLength 22 DamageVariance
|
||
64–75 Resist* 76 Translucency 78 Friction 79 Elasticity
|
||
92 PowerLevel 93 AccuracyLevel 94 AttackAngle
|
||
100 HealkitMod 109 BondWieldedTreasure
|
||
116 WildAttackProbability
|
||
144 ManaConversionMod 147 CriticalFrequency
|
||
149 WeaponMissileDefense 150 WeaponMagicDefense
|
||
152 ElementalDamageMod 155 IgnoreArmor (imbue effect %)
|
||
157 ResistanceModifier 159 AbsorbMagicDamage
|
||
167 CooldownDuration 168 WeaponAuraOffense
|
||
```
|
||
|
||
### Item-relevant PropertyString
|
||
|
||
```
|
||
1 Name 2 Title 7 Inscription
|
||
8 ScribeName 15 ShortDesc 16 LongDesc 17 ActivationTalk
|
||
18 UseMessage 22 ActivationFailure
|
||
25 CraftsmanName (workmanship signature)
|
||
33 Quest 39 TinkerName 40 ImbuerName
|
||
42 DisplayName 47 AllegianceName 52 GearPlatingName
|
||
```
|
||
|
||
### Item-relevant PropertyDataId
|
||
|
||
```
|
||
1 Setup 2 MotionTable 3 SoundTable
|
||
6 PaletteBase 7 ClothingBase 8 Icon
|
||
22 PhysicsEffectTable 24 UseTargetAnimation
|
||
28 Spell (cast on wield) 29 SpellComponent
|
||
36 MutateFilter 37 ItemSkillLimit
|
||
50 IconOverlay 51 IconOverlaySecondary 52 IconUnderlay
|
||
55 ProcSpell (attack-proc spell)
|
||
```
|
||
|
||
### Item-relevant PropertyInstanceId
|
||
|
||
```
|
||
1 Owner 2 Container 3 Wielder 4 Freezer
|
||
17 Creator 18 Victim 22 Bonded 23 Wounder
|
||
24 Allegiance 32 HouseOwner 33 House
|
||
38 AllowedWielder 39 AssignedTarget 40 LimboSource
|
||
31 AllowedActivator
|
||
```
|
||
|
||
---
|
||
|
||
## 4. CreateObject / UpdateObject packet format
|
||
|
||
When a new entity enters the client's PVS (or an existing one changes), the server sends one of:
|
||
|
||
- **CreateObject `0xF745`** — full snapshot.
|
||
- **UpdateObject `0xF7DB`** — forced full re-send (used when the server catches up after drift).
|
||
- **ForceObjectDescSend `0xF6EA`** — header-only re-send.
|
||
|
||
All three use the same three-struct sequence:
|
||
|
||
```
|
||
uint32 ObjectId (GUID)
|
||
ObjectDesc ("model" data) — appearance overlays
|
||
PhysicsDesc ("physics" data) — motion/position/tables
|
||
PublicWeenieDesc ("weenie") — gameplay properties, flag-gated
|
||
```
|
||
|
||
### ObjectDesc layout (model)
|
||
|
||
From `WorldObject_Networking.cs::SerializeModelData`:
|
||
|
||
```
|
||
byte 0x11 // magic / version tag
|
||
byte numSubPalettes
|
||
byte numTextureChanges
|
||
byte numAnimPartChanges
|
||
if numSubPalettes > 0:
|
||
packed-DWORD-of-known-type(0x04000000) PaletteID
|
||
loop numSubPalettes:
|
||
packed-DWORD-of-known-type(0x04000000) SubPaletteId
|
||
byte offset
|
||
byte length
|
||
loop numTextureChanges:
|
||
byte partIndex
|
||
packed-DWORD-of-known-type(0x05000000) OldTexture
|
||
packed-DWORD-of-known-type(0x05000000) NewTexture
|
||
loop numAnimPartChanges:
|
||
byte index
|
||
packed-DWORD-of-known-type(0x01000000) AnimationId
|
||
align to 4
|
||
```
|
||
|
||
"Packed-DWORD-of-known-type" is: if the high nybble equals the expected type (0x04/0x05/0x01/0x06), the writer omits those bits and emits a compact form. ACE does this via `WritePackedDwordOfKnownType`.
|
||
|
||
### PhysicsDesc layout
|
||
|
||
Gated by `PhysicsDescriptionFlag` (uint32 flags at the start). Field order (per ACE `SerializePhysicsData`):
|
||
|
||
```
|
||
uint32 physicsDescriptionFlag
|
||
uint32 physicsState
|
||
if Movement: uint32 dataLength; byte[dataLength] MovementData; uint32 IsAutonomous
|
||
else if AnimationFrame: uint32 Placement
|
||
if Position: PositionPack (32 bytes: uint32 landCell, 3×float3 origin+rotation+unit-quat)
|
||
if MTable: uint32 MotionTableId
|
||
if STable: uint32 SoundTableId
|
||
if PeTable: uint32 PhysicsTableId
|
||
if CSetup: uint32 SetupId
|
||
if Parent: uint32 parentGuid; uint32 parentLocation
|
||
if Children: uint32 numChildren; loop {uint32 guid; uint32 location;}
|
||
if ObjScale: float scale
|
||
if Friction: float friction
|
||
if Elasticity: float elasticity
|
||
if Translucency: float translucency
|
||
if Velocity: float3 velocity
|
||
if Acceleration: float3 acceleration
|
||
if Omega: float3 omega
|
||
if DefaultScript: uint32 scriptId
|
||
if DefaultScriptIntensity: float intensity
|
||
then sequences:
|
||
uint16 posSeq, ushort moveSeq, ushort teleSeq, ushort forceSeq,
|
||
ushort objectSeq, ushort descSeq,
|
||
(possibly instanceSeq).
|
||
align to 4
|
||
```
|
||
|
||
(Full enumeration in `PhysicsDescriptionFlag`; for items specifically, when an item is **in inventory** the server mostly sends `CSetup | MTable | STable | PeTable | ObjScale` without `Position/Velocity/Omega` since the item isn't in the world.)
|
||
|
||
### PublicWeenieDesc layout — the item game-data
|
||
|
||
This is **the** canonical item struct. Verified in `Chorizite.ACProtocol/Types/PublicWeenieDesc.generated.cs`. Field order, with `WeenieHeaderFlag` bit to enable each optional slot:
|
||
|
||
```
|
||
Always:
|
||
uint32 Header (WeenieHeaderFlag bitfield)
|
||
WString16L Name (length-prefixed UTF-16-LE, padded)
|
||
packedDWORD WeenieClassId
|
||
packedDWORD Icon (dat id minus 0x06000000)
|
||
uint32 Type (ItemType)
|
||
uint32 Behavior (ObjectDescriptionFlag)
|
||
align(4)
|
||
|
||
if (Behavior & 0x04000000 IncludesSecondHeader):
|
||
uint32 Header2 (WeenieHeaderFlag2 bitfield)
|
||
|
||
Per-flag (in exact order):
|
||
0x00000001 PluralName : WString16L
|
||
0x00000002 ItemsCapacity : byte
|
||
0x00000004 ContainersCapacity: byte
|
||
0x00000100 AmmoType : uint16
|
||
0x00000008 Value : uint32
|
||
0x00000010 Usable : uint32 (Usable enum)
|
||
0x00000020 UseRadius : float32
|
||
0x00080000 TargetType : uint32 (ItemType)
|
||
0x00000080 Effects : uint32 (IconHighlight / UiEffects)
|
||
0x00000200 CombatUse : byte (WieldType)
|
||
0x00000400 Structure : uint16
|
||
0x00000800 MaxStructure : uint16
|
||
0x00001000 StackSize : uint16
|
||
0x00002000 MaxStackSize : uint16
|
||
0x00004000 ContainerId : uint32 (host container GUID)
|
||
0x00008000 WielderId : uint32 (wielder GUID)
|
||
0x00010000 ValidLocations : uint32 (EquipMask)
|
||
0x00020000 CurrentWieldedLocation: uint32 (EquipMask)
|
||
0x00040000 Priority (ClothingPriority): uint32 (CoverageMask)
|
||
0x00100000 BlipColor : byte (RadarColor)
|
||
0x00800000 RadarEnum : byte (RadarBehavior)
|
||
0x08000000 PhysicsScript : uint16
|
||
0x01000000 Workmanship : float32
|
||
0x00200000 Burden : uint16 (EncumbranceVal truncated)
|
||
0x00400000 SpellId : uint16
|
||
0x02000000 OwnerId : uint32
|
||
0x04000000 Restrictions : RestrictionDB (house perms)
|
||
0x20000000 HookItemTypes : uint16 (HookType)
|
||
0x00000040 MonarchId : uint32
|
||
0x10000000 HookType : uint16
|
||
0x40000000 IconOverlay : packedDWORD (minus 0x06000000)
|
||
Header2-0x01 IconUnderlay : packedDWORD (minus 0x06000000)
|
||
0x80000000 Material : uint32 (MaterialType)
|
||
Header2-0x02 CooldownId : uint32
|
||
Header2-0x04 CooldownDuration: uint64 (or double, ACE has it as double)
|
||
Header2-0x08 PetOwnerId : uint32
|
||
align(4)
|
||
```
|
||
|
||
The absence of a flag means the client uses the dat's weenie-template default (or the value is "unset"). **This is why tiny items (a stone, a stick) ship tiny packets** — only 2–3 flags set; a max-gear rare ships dozens.
|
||
|
||
---
|
||
|
||
## 5. Appraise flow
|
||
|
||
### Request (C2S)
|
||
|
||
`Item_Appraise` — GameAction `0x00C8` (`IdentifyObject`):
|
||
|
||
```
|
||
uint32 ObjectId // the target GUID
|
||
```
|
||
|
||
That's it. The client sends this when the player right-clicks an item and picks "Assess". Fragment is wrapped in a `0xF7B1 GameAction`.
|
||
|
||
### Response (S2C)
|
||
|
||
`GameEvent 0x00C9 IdentifyObjectResponse` (inside a `0xF7B0` GameEvent envelope). Structure is `Item_SetAppraiseInfo`:
|
||
|
||
```
|
||
uint32 ObjectId
|
||
uint32 Flags // IdentifyResponseFlags bitfield
|
||
bool Success // one byte, 0/1 — false = "you don't know enough"
|
||
|
||
// Flag-gated blocks (in this order):
|
||
0x0001 IntStatsTable : PackableHashTable<ushort,int32>
|
||
0x2000 Int64StatsTable : PackableHashTable<ushort,int64>
|
||
0x0002 BoolStatsTable : PackableHashTable<ushort,uint32-bool>
|
||
0x0004 FloatStatsTable : PackableHashTable<ushort,double>
|
||
0x0008 StringStatsTable : PackableHashTable<ushort,WString16L>
|
||
0x1000 DidStatsTable : PackableHashTable<ushort,uint32>
|
||
0x0010 SpellBook : PackableList<uint32 spellId>
|
||
// top-bit (0x80000000) flags an aura/enchant
|
||
0x0080 ArmorProfile : ArmorProfile struct
|
||
0x0100 CreatureProfile : CreatureAppraisalProfile struct
|
||
0x0020 WeaponProfile : WeaponProfile struct
|
||
0x0040 HookProfile : HookAppraisalProfile struct
|
||
0x0200 ArmorEnchantmentBitfield: uint16 highlight, uint16 color
|
||
0x0800 WeaponEnchantmentBitfield: uint16 highlight, uint16 color
|
||
0x0400 ResistEnchantmentBitfield: uint16 highlight, uint16 color
|
||
0x4000 ArmorLevels : 9× uint32 per-body-part AL (head, chest, groin, bicep, wrist, hand, thigh, shin, foot)
|
||
```
|
||
|
||
### PackableHashTable format
|
||
|
||
```
|
||
ushort count
|
||
ushort numBuckets // power-of-2, comparer determines
|
||
// followed by count entries, sorted by PropertyIntComparer (bucketed hash order,
|
||
// not numeric order). Within a bucket, entries are in insertion order.
|
||
loop count:
|
||
uint32 key // upcast from ushort property enum
|
||
value // type depends on the table
|
||
```
|
||
|
||
ACE writes:
|
||
- `PropertyIntComparer(numBuckets=16)`, `PropertyInt64Comparer(8)`, `PropertyBoolComparer(8)`, `PropertyFloatComparer(8)`, `PropertyStringComparer(8)`, `PropertyDataIdComparer(8)`. These must match exactly or the retail client displays garbage.
|
||
|
||
### Assessment success logic
|
||
|
||
The server's `Player_Assess.cs` (not open here, but evidenced by `PropertyBool.AppraisalHasAllowedWielder` and `PropertyInt.AppraisalItemSkill`) uses the **Assessment** skill (id 0x10, found in the chat/skill dispatcher at `chunk_006B0000.c:1245`). Skill check formula follows `SkillCheck.GetSkillChance(playerSkill, itemDifficulty)` — a standard AC S-curve where 50% chance occurs at `skill == difficulty`. Ten-hit retry timer (`AppraisalHeartbeatDueTimestamp`, `AppraisalRequestedTimestamp`) prevents spam.
|
||
|
||
Items with `ItemDifficulty = 0` or NPCs always succeed (from `AppraiseInfo.BuildProperties` — `Success = true` default). Failure still sends a `IdentifyObjectResponse` but with `Success = false` and an empty table set (most properties stripped).
|
||
|
||
---
|
||
|
||
## 6. Burden and bulk (encumbrance)
|
||
|
||
### Capacity formula
|
||
|
||
From `ACE.Server/Physics/Common/EncumbranceSystem.cs` (canonical, matches decompiled retail):
|
||
|
||
```csharp
|
||
int EncumbranceCapacity(int strength, int numAugs)
|
||
{
|
||
if (strength <= 0) return 0;
|
||
int bonusBurden = 30 * numAugs; // AugmentationIncreasedCarryingCapacity: up to 5
|
||
if (bonusBurden >= 0) {
|
||
if (bonusBurden > 150) bonusBurden = 150;
|
||
return 150 * strength + strength * bonusBurden;
|
||
}
|
||
return 150 * strength;
|
||
}
|
||
```
|
||
|
||
A level-1 character with `Strength = 10` has capacity `150 × 10 = 1500` burden units. A level-275 character with `Strength = 400` and 5 augs has `150 × 400 + 400 × 150 = 60000 + 60000 = 120000` units.
|
||
|
||
### Carry limit
|
||
|
||
`Player_Inventory.cs::HasEnoughBurdenToAddToInventory`:
|
||
|
||
```csharp
|
||
return (EncumbranceVal + newItemBurden) <= (GetEncumbranceCapacity() * 3);
|
||
```
|
||
|
||
**The hard carry-cap is `3 × capacity`.** At `2× capacity` you're "over-encumbered" and your movement-speed mod (`GetBurdenMod`) drops to 0; between `1× and 2×` it linearly interpolates `2 - burden` in multiplier; at `<1×` it's 1.0.
|
||
|
||
```csharp
|
||
float GetBurdenMod(float burden /* = encumbrance / capacity */)
|
||
{
|
||
if (burden < 1.0f) return 1.0f;
|
||
if (burden < 2.0f) return 2.0f - burden;
|
||
return 0.0f;
|
||
}
|
||
```
|
||
|
||
So the three breakpoints are:
|
||
|
||
| Carried | UI message | Movement mod |
|
||
|---|---|---|
|
||
| 0–100 % | normal | 1.0 |
|
||
| 100–200 % | "burdened" | 1.0 → 0.0 linearly |
|
||
| 200–300 % | "over-burdened" | 0.0 (can't run) |
|
||
| > 300 % | **cannot pick up** (error `0x2A` — "You are too encumbered to carry that!") |
|
||
|
||
The WError codes `0x2A` and `0x2B` from `chunk_00570000.c:1853–1858` are these hit.
|
||
|
||
### Bulk (slots)
|
||
|
||
Unlike burden, bulk is **item count per container** — not mass-proportional. Main backpack: `ItemsCapacity` property (max 102 for the Player — Player_Inventory doesn't check it past base 102). Side packs: `ContainersCapacity` property (base 7 slots) — each slot holds one side pack (regular pack, foci, or pouch).
|
||
|
||
Side pack capacity: `ItemsCapacity` on the pack weenie itself — retail packs are 24 items, pouches typically 12, foci are `ContainerType.Foci` and are slot-exclusive with no sub-items. `Container.cs::GetFreeInventorySlots(includeSidePacks=true)` sums main + all side-pack free slots.
|
||
|
||
---
|
||
|
||
## 7. Container hierarchy
|
||
|
||
### Depth model
|
||
|
||
Retail AC permits **exactly two levels**:
|
||
|
||
```
|
||
Player (primary Container)
|
||
├── Main pack items (ItemsCapacity slots, 102 total in a maxed player)
|
||
├── Side pack 1 (Container) // uses a ContainerCapacity slot
|
||
│ └── items (ItemsCapacity slots of the sub-pack)
|
||
├── Side pack 2 (Container)
|
||
│ └── items
|
||
├── Foci (ContainerType.Foci) // ContainerCapacity slot, no inventory
|
||
└── ...
|
||
```
|
||
|
||
**A side pack may not contain another side pack.** This is enforced both server-side (in `Container.AddToInventory`) and by the client paperdoll drag handler. From `Container.cs`:
|
||
|
||
```csharp
|
||
private int CountContainers() =>
|
||
Inventory.Values.Count(wo => wo.UseBackpackSlot); // UseBackpackSlot = (WeenieType.Container || IsFoci)
|
||
```
|
||
|
||
`UseBackpackSlot` is true for both sub-containers and foci. Nested containers route to side-pack slots; anything else goes to main.
|
||
|
||
### Encoding
|
||
|
||
Each item has:
|
||
- `PropertyInstanceId.Container = 2` — its direct parent container's GUID.
|
||
- `PropertyInt.PlacementPosition = 53` — 0-based slot index within that container (sorted by `OrderBy(wo => wo.PlacementPosition)`).
|
||
|
||
So a side pack's contents are enumerated by walking `Inventory[]` for items whose `Container == sidePack.Guid`, ordered by `PlacementPosition`.
|
||
|
||
The wire `PublicWeenieDesc.ContainerId` field (WeenieHeaderFlag 0x00004000) directly carries this. On `CreateObject`, if an item is in a pack, it's placed inside `PublicWeenieDesc` with `ContainerId = pack.Guid`; on `WieldObject`, `WielderId` is set and `ContainerId` is cleared.
|
||
|
||
### `InventoryPutObjInContainer` event (GameEvent 0x0022)
|
||
|
||
S2C event structure (`GameEventItemServerSaysContainId`):
|
||
|
||
```
|
||
uint32 itemGuid
|
||
uint32 containerGuid
|
||
uint32 placementPosition
|
||
uint32 ContainerType // 0=NonContainer, 1=Container, 2=Foci (from ACE.Entity.Enum.ContainerType)
|
||
```
|
||
|
||
This fires whenever the server slots an item into a container — either player-initiated (picked up, moved) or server-initiated (loot spawned in corpse).
|
||
|
||
---
|
||
|
||
## 8. Stacking rules
|
||
|
||
### Which items stack
|
||
|
||
An item is stackable iff `MaxStackSize > 1`. In practice: pyreals (10000 cap), gems (100), arrows (100), scrolls (1 — single-use), spell components (1000 for taper-family, varies), salvage bundles (100 pieces). `WeenieType.Coin` and `WeenieType.Stackable` and `WeenieType.SpellComponent` are the main "stacks by default" categories.
|
||
|
||
### Merge rules (from ACE's `DoHandleActionStackableMerge`)
|
||
|
||
- Both source + target must have the same `WeenieClassId`.
|
||
- Both must be unenchanted (`!IsEnchanted`). WError `0x428` — "You cannot merge enchanted items!".
|
||
- The source must belong to the player OR both must belong to the player (WError `0x429` — "You must control at least one stack!").
|
||
- Target `StackSize + amount <= MaxStackSize`, else an overflow stack remains as the source.
|
||
|
||
### Split actions (C2S)
|
||
|
||
- `StackableMerge 0x0054`: `ObjectId (source) + TargetId (target) + Amount`.
|
||
- `StackableSplitToContainer 0x0055`: `ObjectId + ContainerId + SlotIndex + Amount` — makes a new stack of `Amount` inside a container.
|
||
- `StackableSplitTo3D 0x0056`: `ObjectId + Amount` — drops a split stack to the ground.
|
||
- `StackableSplitToWield 0x019B`: `ObjectId + SlotIndex (EquipMask) + Amount` — equip a split (e.g. arrows directly to missile ammo).
|
||
|
||
### `Item_UpdateStackSize` S2C (opcode 0x0197)
|
||
|
||
```
|
||
byte Sequence
|
||
uint32 ObjectId
|
||
uint32 Amount // new stack size
|
||
uint32 NewValue // new total Value (pyreals)
|
||
```
|
||
|
||
Fired when a stack changes size without changing GUID (e.g. merging adds to target). If the source is entirely consumed, a `ObjectDelete 0xF747` follows.
|
||
|
||
---
|
||
|
||
## 9. Equipment and wield
|
||
|
||
### Equip request (C2S `GetAndWieldItem 0x001A`)
|
||
|
||
```
|
||
uint32 ObjectId // item GUID
|
||
uint32 Slot // EquipMask — which slot we want
|
||
```
|
||
|
||
The server validates in order:
|
||
1. Item belongs to the player or is in an accessible container (WError `0x28` — "The item is under someone else's control!").
|
||
2. Item is `MagicWieldable`, `Weapon`, `Armor`, etc — matches its `ValidLocations`.
|
||
3. `Slot` is one of the bits in `ValidLocations`.
|
||
4. Level / skill / attribute requirements via `PropertyInt.WieldRequirements..4`:
|
||
- `WieldRequirement.Level` — needs `Player.Level >= WieldDifficulty`.
|
||
- `WieldRequirement.Attrib` / `RawAttrib` — needs `Attribute.Current >= WieldDifficulty`.
|
||
- `WieldRequirement.Skill` / `RawSkill` — `Skill.Current >= Difficulty` for the `WieldSkillType`.
|
||
- `WieldRequirement.HeritageType` — species lock.
|
||
5. Activation: `ItemAllegianceRankLimit`, `ItemSkillLevelLimit`, `Attuned`, `Gender`, etc.
|
||
6. For PK-bound items: `PropertyInstanceId.AllowedWielder` must equal player GUID.
|
||
7. Carrying-capacity check if it came from outside the player's inventory.
|
||
|
||
On success, the server sends:
|
||
|
||
- `GameEventWieldItem 0x0023` — instructs the paperdoll to display the item in `Slot`:
|
||
```
|
||
uint32 itemGuid
|
||
uint32 slot (EquipMask)
|
||
```
|
||
- Follow-up: `UpdateObject 0xF7DB` with `WielderId = player.Guid`, `CurrentWieldedLocation = Slot`, `ContainerId = 0`.
|
||
|
||
### Un-equip
|
||
|
||
Client sends a `PutItemInContainer 0x0019` targeting the player's own GUID as the container. Server revokes wielded status and slots the item back into main pack. Visual equipment on other players is driven by the `ObjDescEvent 0xF625` (appearance re-send) after the wield/un-wield.
|
||
|
||
---
|
||
|
||
## 10. Currency
|
||
|
||
Retail AC has three economic currencies:
|
||
|
||
1. **Pyreals** — weenie `273`, `WeenieType.Coin`, `ItemType.Money`. Max stack **10000** per stack (capped explicitly in starter gear and loot code). A character can carry multiple 10000-pyreal stacks; `PropertyInt.CoinValue` sums them across main pack + side packs (ephemeral, recalculated on inventory change via `UpdateCoinValue`).
|
||
2. **Luminance** — `PropertyInt64.AvailableLuminance` (id 6) / `PropertyInt64.MaximumLuminance` (id 7). Not an item — it's a player-level int64 tracked as a property. Spent on LuminanceAug vendor services.
|
||
3. **Society tokens / allegiance XP** — Society rank is `PropertyInt.SocietyRankCelhan / Eldweb / Radblo` (ids 287–289), but the currency itself is a physical item (e.g. tokens — weenie 33613 "Pathwarden Token" in the starter gear). `TotalExperience` (int64) is also an XP currency for allegiance redistribution.
|
||
|
||
### Coin overflow
|
||
|
||
When a stack would exceed `MaxStackSize = 10000`, the server **creates a second stack** and places it in the next free slot (`DoHandleActionStackableMerge` creates overflow). Players end up with `Pyreal (10000x)` stacks + a partial one.
|
||
|
||
---
|
||
|
||
## 11. Salvage + tinker state
|
||
|
||
### Workmanship & crafting state
|
||
|
||
`PropertyInt.ItemWorkmanship = 105` — the 1-to-10 workmanship score. Stored as `uint32` on the item.
|
||
`PropertyFloat.Workmanship` in the `PublicWeenieDesc` (flag 0x01000000) is a `float32` — workmanship is shipped twice: the quantized int on the item, and a fractional float on the wire for salvage-bundle averaging.
|
||
|
||
### Tinker slots
|
||
|
||
`PropertyInt.NumTimesTinkered = 171` — 0..10. Each tinker application also adds a string to `PropertyString.TinkerLog = 9007` (or per tinker type).
|
||
`PropertyInt.TinkerLog (str 9007)` is a semicolon-separated log: `"Horns;Leather;Pine"` — displayed in the long description on appraise.
|
||
`PropertyString.TinkerName = 39` and `PropertyString.ImbuerName = 40` — the character names who tinkered/imbued.
|
||
|
||
### Imbue state
|
||
|
||
`PropertyInt.ImbuedEffect = 179` is an `ImbuedEffectType` enum: attack-skill raising, critical-freq raising, slash/pierce/blunt/fire/cold/acid/electric damage raising, armor rending, crushing blow, biting strike, hematite (lifesteal), gurog (mana-drain). Additional slots `ImbuedEffect2..5 = 303..306` can stack multiple imbues.
|
||
`ImbueStackingBits = 311` — bitfield for tinker-stacking augmentations (Class A, B, C, Corrupted Amber, Purchased Armor).
|
||
`PropertyInt.ImbueAttempts = 205` / `ImbueSuccesses = 206` — counters.
|
||
|
||
### Salvage-bundle fields
|
||
|
||
`PropertyInt.NumItemsInMaterial = 170` — count of items merged into this bundle.
|
||
`PropertyInt.MaterialType = 131` — see `MaterialType` enum (§12). Bundles all share the same material; mixing materials makes separate bundles.
|
||
`PropertyInt.Structure / MaxStructure = 91/92` — per-unit charge OR bundle count (reused). For a Salvage bundle, `Structure = 1..100` pieces.
|
||
|
||
### Usage tracking
|
||
|
||
`PropertyInt.Structure = 92` / `MaxStructure = 91` track uses remaining for healing kits, ivory kits, lockpicks, tinker tools. `PropertyFloat.UseLockTimestamp = 99` prevents spam-use.
|
||
|
||
---
|
||
|
||
## 12. Active enchantments on items
|
||
|
||
When an item is enchanted (via Item Enchantment spells like Strength Self III, Blood Drinker VI, Infected Caress), the enchantment is stored on the **item's EnchantmentManager** (server-side) and sent to the client through `MagicUpdateEnchantment 0x02C2` events whenever the item is visible.
|
||
|
||
On `Item_SetAppraiseInfo`, the `SpellBook` field (flag 0x0010) contains **two concatenated lists**:
|
||
|
||
1. **Innate spells** — from the item's weenie (fire-and-forget cantrips). Sent as raw `SpellId` (uint32).
|
||
2. **Active auras/enchantments** — sent with top bit set: `spellId | 0x80000000`. The client's appraise window renders these with a different color/icon (usually yellow "aura" marker).
|
||
|
||
`AppraiseInfo.cs` builds this list from `wo.Biota.GetEnchantments()` plus, for weapons, the wielder's `EnchantmentManager.GetEnchantments(MagicSchool.ItemEnchantment)` filtered by `SpellCategory` (e.g. `AttackModRaising`, `DefenseModRaising`, `DamageRaising`). This lets a wielded weapon show **"you see this weapon as if it had Heart Thirst VI on it"** (aura) even though the spell is actually on the wielder.
|
||
|
||
The retail client also decorates the icon with a **blue glow** when there are any active auras — driven by `PropertyInt.UiEffects = 18` (`IconHighlight` enum: Magical, Heroic, Diabolical, Elemental). `UiEffects` is written in the `PublicWeenieDesc` flag 0x00000080 (`Effects`). The value transitions visually to the "magic item" icon overlay.
|
||
|
||
---
|
||
|
||
## 13. Icon, icon-overlay, and icon-underlay
|
||
|
||
Three DID fields compose the displayed item icon (all ResourceIds with type prefix `0x06000000`):
|
||
|
||
| PropertyDataId | Wire field | Purpose |
|
||
|---|---|---|
|
||
| `8 Icon` | `PublicWeenieDesc.Icon` (required) | base image — rendered bottom-most |
|
||
| `50 IconOverlay` | WeenieHeaderFlag `0x40000000` | top decoration — used for "magic item" sparkles |
|
||
| `52 IconUnderlay` | WeenieHeaderFlag2 `0x00000001` | bottom decoration — used for rare-item foil, socketed marker |
|
||
| `51 IconOverlaySecondary` | (not in wire; server-only) | alternate overlay for dye/tier |
|
||
|
||
Overlays in the dat are 32×32 RGBA icons with transparency where the base icon shows through. The client composites in this order: underlay → icon → overlay → text stack-count (rendered by UI). For Aetheria sigils the base icon is the slot (blue/yellow/red gem), the overlay marks activated state.
|
||
|
||
`UiEffects` (`PropertyInt 18`) is **additive** to the overlay — it's a client-side post-process glow, not a dat asset. Values are `IconHighlight.Magical / Heroic / Diabolical / ...` which the retail client renders by tinting the whole icon with a colored additive sprite.
|
||
|
||
---
|
||
|
||
## 14. Retail wire messages — summary
|
||
|
||
### Client → Server (inside `0xF7B1 GameAction` envelope, `C2SMessageType` ordinal)
|
||
|
||
| GameActionType | Opcode | Message | Payload |
|
||
|---|---|---|---|
|
||
| PutItemInContainer | `0x0019` | Inventory_PutItemInContainer | `uint32 objectId; uint32 containerId; uint32 slotIndex` |
|
||
| GetAndWieldItem | `0x001A` | Inventory_GetAndWieldItem | `uint32 objectId; uint32 slot (EquipMask)` |
|
||
| DropItem | `0x001B` | Inventory_DropItem | `uint32 objectId` |
|
||
| StackableMerge | `0x0054` | Inventory_StackableMerge | `uint32 objectId; uint32 targetId; uint32 amount` |
|
||
| StackableSplitToContainer | `0x0055` | Inventory_StackableSplitToContainer | `uint32 objectId; uint32 containerId; uint32 slotIndex; uint32 amount` |
|
||
| StackableSplitTo3D | `0x0056` | Inventory_StackableSplitTo3D | `uint32 objectId; uint32 amount` |
|
||
| StackableSplitToWield | `0x019B` | (analog) | `uint32 objectId; uint32 slot; uint32 amount` |
|
||
| GiveObjectRequest | `0x00CD` | Inventory_GiveObjectRequest | `uint32 targetId; uint32 objectId; uint32 amount` |
|
||
| IdentifyObject (Appraise) | `0x00C8` | Item_Appraise | `uint32 objectId` |
|
||
| Use | `0x0036` | Inventory_UseEvent | `uint32 objectId` |
|
||
| UseWithTarget | `0x0035` | Inventory_UseWithTargetEvent | `uint32 toolId; uint32 targetId` |
|
||
| NoLongerViewingContents | `0x0195` | Inventory_NoLongerViewingContents | `uint32 containerId` |
|
||
|
||
### Server → Client
|
||
|
||
| GameEvent | Opcode | Message |
|
||
|---|---|---|
|
||
| `ObjectCreate` (Item_CreateObject) | `0xF745` | `uint32 guid; ObjectDesc; PhysicsDesc; PublicWeenieDesc` |
|
||
| `UpdateObject` (Item_UpdateObject) | `0xF7DB` | `uint32 guid; ObjectDesc; PhysicsDesc; PublicWeenieDesc` |
|
||
| `ObjectDelete` | `0xF747` | `uint32 guid; uint16 instanceSequence` |
|
||
| `ObjDescEvent` | `0xF625` | `uint32 guid; ObjectDesc` (model-only resync) |
|
||
| `ForceObjectDescSend` | `0xF6EA` | `uint32 guid` (heartbeat / trigger full re-send) |
|
||
| `SetStackSize` (Item_UpdateStackSize) | `0x0197` | `byte seq; uint32 guid; uint32 amount; uint32 newValue` |
|
||
| `InventoryRemoveObject` | `0x0024` | `uint32 guid` |
|
||
| `PickupEvent` (Inventory_PickupEvent) | `0xF74A` | `uint32 guid; uint16 instSeq; uint16 posSeq` |
|
||
| **GameEvent envelope `0xF7B0`**, inner event type: | | |
|
||
| `InventoryPutObjInContainer` | `0x0022` | `uint32 itemId; uint32 containerId; uint32 slot; uint32 ContainerType` |
|
||
| `WieldObject` | `0x0023` | `uint32 itemId; uint32 slot (EquipMask)` |
|
||
| `IdentifyObjectResponse` (Item_SetAppraiseInfo) | `0x00C9` | see §5 |
|
||
| `ViewContents` | `0x0196` | `uint32 containerId; PackableList<ContainedObject>` (guid+weenieClassId+slot) |
|
||
| `ItemAppraiseDone` | `0x01CB` | `uint32 objectId; uint32 successOrFailure` |
|
||
| `CloseGroundContainer` | `0x0052` | `uint32 containerId` |
|
||
| `InventoryServerSaveFailed` | `0x00A0` | `uint32 itemId; uint32 weenieError` (used to roll back failed client-side moves) |
|
||
|
||
### WeenieError codes (client displays — from `chunk_00570000.c`)
|
||
|
||
Item-relevant subset:
|
||
|
||
```
|
||
0x29 "You cannot pick that up!"
|
||
0x2A "You are too encumbered to carry that!"
|
||
0x2B "<x> cannot carry anymore."
|
||
0x20 "You must control both objects!"
|
||
0x23,0x37-0x39 "Unable to move to object!"
|
||
0x26 "That is not a valid command."
|
||
0x28 "The item is under someone else's control!"
|
||
0x3EE "The container is closed!"
|
||
0x427 "You cannot merge different stacks!"
|
||
0x428 "You cannot merge enchanted items!"
|
||
0x429 "You must control at least one stack!"
|
||
0x3EF "<name> is not accepting gifts right now."
|
||
0x46A "<name> doesn't know what to do with that."
|
||
```
|
||
|
||
---
|
||
|
||
## 15. Port plan — acdream C# classes
|
||
|
||
### Core model
|
||
|
||
```csharp
|
||
namespace Acdream.Items;
|
||
|
||
// An item's definition (read-only, shared)
|
||
public sealed class ItemTemplate
|
||
{
|
||
public uint WeenieClassId;
|
||
public WeenieType WeenieType;
|
||
public string Name;
|
||
public string PluralName;
|
||
public uint IconDid; // 0x06xxxxxx — rendered via R4
|
||
public uint IconOverlayDid;
|
||
public uint IconUnderlayDid;
|
||
public ItemType Type; // bitfield
|
||
public EquipMask ValidLocations;
|
||
public ushort MaxStackSize;
|
||
public ushort BaseBurden;
|
||
public uint BaseValue;
|
||
public PropertyBundle DefaultProperties; // the dat-derived defaults
|
||
}
|
||
|
||
// A live instance — the thing the server owns
|
||
public sealed class ItemInstance
|
||
{
|
||
public uint Guid; // unique per session
|
||
public ItemTemplate Template;
|
||
public PropertyBundle Properties; // overrides + runtime state
|
||
public uint? ContainerGuid; // PropertyInstanceId.Container
|
||
public uint? WielderGuid; // PropertyInstanceId.Wielder
|
||
public EquipMask CurrentSlot; // PropertyInt.CurrentWieldedLocation
|
||
public ushort StackSize; // cache of PropertyInt.StackSize
|
||
public int PlacementPosition;
|
||
|
||
public int EncumbranceVal =>
|
||
Properties.GetInt(PropertyInt.EncumbranceVal) ??
|
||
Template.BaseBurden * Math.Max(1, StackSize);
|
||
public int Value => Properties.GetInt(PropertyInt.Value) ?? (int)Template.BaseValue;
|
||
public bool IsStackable => Template.MaxStackSize > 1;
|
||
public bool IsContainer => Template.WeenieType == WeenieType.Container;
|
||
}
|
||
```
|
||
|
||
### PropertyBundle — the 7-table dictionary
|
||
|
||
```csharp
|
||
public sealed class PropertyBundle
|
||
{
|
||
public Dictionary<PropertyInt, int> Ints = new();
|
||
public Dictionary<PropertyInt64, long> Int64s = new();
|
||
public Dictionary<PropertyBool, bool> Bools = new();
|
||
public Dictionary<PropertyFloat, double> Floats = new();
|
||
public Dictionary<PropertyString, string> Strings = new();
|
||
public Dictionary<PropertyDataId, uint> DataIds = new();
|
||
public Dictionary<PropertyInstanceId,uint> InstanceIds = new();
|
||
|
||
public int? GetInt(PropertyInt k) => Ints.TryGetValue(k, out var v) ? v : null;
|
||
// ... etc
|
||
|
||
// Merge an incoming AppraiseInfo table into this bundle (for identify responses)
|
||
public void MergeFromAppraise(PropertyBundle other) { /* ... */ }
|
||
|
||
// Wire serialization — binary-identical to ACE AppraiseInfo.cs §5
|
||
public void WritePackableHashTable<TKey, TValue>(BinaryWriter w,
|
||
Dictionary<TKey, TValue> dict, int numBuckets) { /* ... */ }
|
||
}
|
||
```
|
||
|
||
### Container
|
||
|
||
```csharp
|
||
public class Container : ItemInstance
|
||
{
|
||
public List<ItemInstance> MainPack = new(); // ItemsCapacity slots
|
||
public List<Container> SidePacks = new(); // ContainersCapacity slots
|
||
|
||
public int ItemsCapacity => Properties.GetInt(PropertyInt.ItemsCapacity) ?? 0;
|
||
public int ContainersCapacity => Properties.GetInt(PropertyInt.ContainersCapacity) ?? 0;
|
||
|
||
public int GetFreeInventorySlots(bool includeSidePacks = true) { /* ... */ }
|
||
public bool TryAddItem(ItemInstance item, int slot) { /* ... */ }
|
||
public bool TryMoveItem(ItemInstance item, Container target, int slot) { /* ... */ }
|
||
}
|
||
```
|
||
|
||
### InventoryManager (top-level)
|
||
|
||
```csharp
|
||
public sealed class InventoryManager
|
||
{
|
||
public Player Owner;
|
||
public Container MainInventory; // = Owner itself
|
||
public Dictionary<EquipMask, ItemInstance> Equipped = new();
|
||
public Dictionary<uint, ItemInstance> ByGuid = new();
|
||
|
||
public int GetEncumbranceCapacity()
|
||
{
|
||
int str = Owner.Attributes[Attribute.Strength].Current;
|
||
int augs = Owner.Properties.GetInt(PropertyInt.AugmentationIncreasedCarryingCapacity) ?? 0;
|
||
return EncumbranceSystem.EncumbranceCapacity(str, augs);
|
||
}
|
||
|
||
public bool HasEnoughBurdenToAddToInventory(int addBurden) =>
|
||
Owner.EncumbranceVal + addBurden <= GetEncumbranceCapacity() * 3;
|
||
|
||
// Event handlers from the wire layer
|
||
public void OnCreateObject(ItemInstance item);
|
||
public void OnUpdateObject(ItemInstance item);
|
||
public void OnDeleteObject(uint guid);
|
||
public void OnPutObjInContainer(uint item, uint container, int slot, ContainerType type);
|
||
public void OnWieldObject(uint item, EquipMask slot);
|
||
public void OnUpdateStackSize(uint item, uint newSize, uint newValue);
|
||
public void OnAppraiseInfo(uint item, PropertyBundle props, IdentifyResponseFlags flags, bool success);
|
||
|
||
// Commands the UI issues upward
|
||
public Task MoveItemAsync(uint item, uint container, int slot);
|
||
public Task SplitStackAsync(uint item, uint container, int slot, int amount);
|
||
public Task MergeStackAsync(uint source, uint target, int amount);
|
||
public Task DropItemAsync(uint item);
|
||
public Task WieldItemAsync(uint item, EquipMask slot);
|
||
public Task GiveItemAsync(uint item, uint target, int amount);
|
||
public Task AppraiseAsync(uint item);
|
||
}
|
||
```
|
||
|
||
### Integration with R6 UI
|
||
|
||
The retail paperdoll at `chunk_004A0000.c` dispatches drag/drop through fixed widget offsets (§2). In acdream we mirror that with a `Paperdoll` UI control that binds each slot to its `EquipMask` bit. UI framework events map 1:1:
|
||
|
||
- Drag-enter over a slot with no held item → show tooltip "Drag X here to wear them" (translate slot bit → drop-hint string verbatim).
|
||
- Drag-enter with a held item → show "ItemName (worn)/(wielded)\nDouble-click to take off/unwield".
|
||
- Drag-drop → call `InventoryManager.WieldItemAsync(item, slotBit)`.
|
||
- Double-click on an equipped slot → `MoveItemAsync(item, player, nextFreeSlot)`.
|
||
|
||
Events surfaced via the `IEvents` plugin API should include:
|
||
- `InventoryChanged(ItemInstance added, ItemInstance removed)` — allows a pickup tally plugin.
|
||
- `EquipmentChanged(EquipMask slot, ItemInstance? item)` — paperdoll subscribers.
|
||
- `AppraiseReceived(ItemInstance item, bool success)` — shows the appraise popup.
|
||
- `StackSizeChanged(ItemInstance item, ushort from, ushort to)` — coin counter plugins.
|
||
|
||
Plugins interact via these events and may issue commands through an `IInventoryCommands` (a plugin-safe subset of `InventoryManager`'s public methods). **Drag-drop hooks to `0x15` / `0x3E` are inventory events fired on the plugin bus** — the dispatcher maps item GUIDs to human-readable names for macro authors.
|
||
|
||
---
|
||
|
||
## 16. Port conformance tests
|
||
|
||
At minimum acdream must port 5+ conformance tests into its test suite (mirror ACE's style):
|
||
|
||
### Test 1 — known weenie properties round-trip
|
||
|
||
Load a known dat weenie (e.g. weenie 273, Pyreal) and assert:
|
||
- `WeenieType == Coin`, `ItemType == Money`, `MaxStackSize == 10000`, `BaseBurden == 4` (retail value), `Icon == 0x0600109c` (pyreal icon).
|
||
- A fresh `ItemInstance` with `StackSize = 100` has `EncumbranceVal == 400` and `Value == 100`.
|
||
|
||
### Test 2 — burden formula matrix
|
||
|
||
Port `EncumbranceSystem.EncumbranceCapacity` and `GetBurden`, `GetBurdenMod` exactly. Table-test:
|
||
|
||
| strength | augs | expected capacity | burden=1.0*cap mod | burden=1.5*cap mod | burden=2.5*cap mod |
|
||
|---|---|---|---|---|---|
|
||
| 10 | 0 | 1500 | 1.0 | 0.5 | 0.0 |
|
||
| 100 | 0 | 15000 | 1.0 | 0.5 | 0.0 |
|
||
| 200 | 1 | 36000 | 1.0 | 0.5 | 0.0 |
|
||
| 400 | 5 | 120000 | 1.0 | 0.5 | 0.0 |
|
||
| 0 | 0 | 0 | 3.0 (infinite) | 3.0 | 3.0 |
|
||
|
||
### Test 3 — PublicWeenieDesc byte-exact round-trip
|
||
|
||
Serialize a fixed `ItemInstance` for a dagger with `Name="Darkmoon Dagger"`, `StackSize=1`, `ValidLocations=Held|TwoHanded`, `Workmanship=7.5`, `MaterialType=Steel`, and compare the exact byte output against a golden hex capture from a live retail pcap. Flags and field order must match `PublicWeenieDesc.generated.cs` §4.
|
||
|
||
Edge cases:
|
||
- A pyreal stack of 10000: flags `Value | StackSize | MaxStackSize | Burden | Container`.
|
||
- A locked chest: flags `ItemsCapacity | ContainersCapacity | Value | Usable | UseRadius | Structure`. Include `ObjectDescriptionFlag.Openable | Stuck`.
|
||
- An equipped cloak: flags `Value | ValidLocations | CurrentWieldedLocation | Priority | Burden | Wielder | IconOverlay`.
|
||
|
||
### Test 4 — PackableHashTable bucketing
|
||
|
||
Given a fixed set of 5 `PropertyInt` entries (e.g. `Value=1000, Burden=5, ItemType=2, Workmanship=8, StackSize=1`), verify the emitted `PackableHashTable` bucket order matches ACE's `PropertyIntComparer(16)` exactly. Hand-compute expected order from the ACE comparer hash or cross-check against a captured pcap.
|
||
|
||
### Test 5 — stack split/merge invariants
|
||
|
||
Given two stacks:
|
||
- Source: `Value=3000, StackSize=3, MaxStackSize=10000` (3 pyreals).
|
||
- Target: `Value=5000, StackSize=5, MaxStackSize=10000` (5 pyreals).
|
||
|
||
Merging with `amount=3` yields:
|
||
- Target: `StackSize=8, Value=8000`.
|
||
- Source: removed (OnDelete fires).
|
||
|
||
Merging with `amount=3` when target would exceed max:
|
||
- Target `StackSize=9998`, merge amount=3 → Target `StackSize=10000`, Source `StackSize=1`.
|
||
- Fire `SetStackSize` for both; GUIDs stay stable; no `ObjectDelete`.
|
||
|
||
Splitting a stack of 100 pyreals by 40:
|
||
- Original: `StackSize=60, Value=60*unit`.
|
||
- New stack (new GUID): `StackSize=40, Value=40*unit`.
|
||
- Fire `CreateObject` for the new stack, `SetStackSize` for the old stack.
|
||
|
||
### Test 6 — appraise response flag gating
|
||
|
||
Build an `AppraiseInfo` with only:
|
||
- `PropertiesInt = {ItemType=1}`
|
||
- `Success = true`
|
||
|
||
Verify:
|
||
- `Flags == IdentifyResponseFlags.IntStatsTable`.
|
||
- Serialized output starts with `[Flags:uint32] [Success:uint32] [PackableHashTable<PropInt,int>:...]` — no subsequent blocks emitted (because no other flag bits are set).
|
||
|
||
Then build with an `ArmorProfile`:
|
||
- `Flags == IdentifyResponseFlags.IntStatsTable | ArmorProfile = 0x0081`.
|
||
- After the int table comes `ArmorProfile` serialized.
|
||
|
||
### Test 7 — paperdoll drop-hint strings
|
||
|
||
For each `EquipMask` slot bit, fetch the drop-hint string and compare against the retail table from §2. **Any string mismatch = regression.** This is the visual-parity gate.
|
||
|
||
### Test 8 — two-level container depth enforcement
|
||
|
||
Attempt to add a side pack into another side pack:
|
||
- Server rejects; verify acdream client refuses the move and shows WError `0x26` ("That is not a valid command.") or equivalent.
|
||
|
||
Attempt to add a non-container to a side-pack sub-slot:
|
||
- Should go to the side pack's main-pack list, not the `SidePacks` list.
|
||
|
||
---
|
||
|
||
## Summary of key facts for implementation
|
||
|
||
1. **ItemType is a 32-bit bitfield**, not enum. Use `[Flags]` in C# and serialize as `uint32`.
|
||
2. **EquipMask is a 32-bit bitfield** that is *both* the set of valid slots and the currently-wielded slot. 31 slot bits + `TwoHanded` meta-bit.
|
||
3. **The property model is seven tables keyed by ushort**. Each table is sent independently on the wire, gated by its flag bit. Bucketed hash order matters — comparers must match.
|
||
4. **Burden capacity = `150 × Strength + Strength × bonusBurden`** where `bonusBurden = 30 × augLevel` capped at 150. **Carry limit is 3×capacity.** Movement drops to 0 between 2× and 3× carry.
|
||
5. **Containers are max two-deep.** Main pack + side packs; no side-pack-in-side-pack. `UseBackpackSlot = WeenieType == Container || ContainerType == Foci`.
|
||
6. **Stack messages** share a common (source, target, amount) pattern for merge/split variants; the wire opcodes for split differ by destination (Container=0x0055, 3D=0x0056, Wield=0x019B).
|
||
7. **CreateObject always carries three structs**: `ObjectDesc` (model), `PhysicsDesc` (motion), `PublicWeenieDesc` (game data). For items in inventory, the physics half is minimal; for items wielded, `WielderId` + `CurrentWieldedLocation` are set.
|
||
8. **The paperdoll UI slots at offsets `0x604..0x660`** define the visual-to-slot mapping. Every drop-hint string is a load-bearing retail asset — reproduce verbatim.
|
||
9. **The Appraise response** can optionally include armor/creature/weapon/hook profiles as **nested structs after the property tables**, and three enchantment-highlight pairs as `ushort` bitfields.
|
||
10. **Icons are `0x06000000`-family DIDs**; overlays (enchanted glow, rare foil) composite on top. `UiEffects` (PropertyInt 18) adds a client-side post-process tint — it is not a dat asset.
|
||
|
||
This is the foundation. Every other item-facing system (salvage UI, trade window, vendor list, house hook, crafting panel, weapon combat-mode indicator) reads from the same `PropertyBundle` on the same `ItemInstance` — get this model right and the rest is UI.
|