acdream/docs/research/deepdives/r06-items-inventory.md
Erik 3f913f1999 docs+feat: 13 retail-AC deep-dives (R1-R13) + C# port scaffolds + roadmap E-H
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.
2026-04-18 10:32:44 +02:00

56 KiB
Raw Blame History

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.exedocs/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.csSerializeCreateObject, 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.
  • ArmorExclusiveArmor 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 (0390 + custom ≥8000) 0x0001 IntStatsTable
PropertyInt64 ushort int64 PropertyInt64 (08 + custom) 0x2000 Int64StatsTable
PropertyBool ushort uint32 (as bool) PropertyBool (0130 + custom) 0x0002 BoolStatsTable
PropertyFloat ushort double (8 bytes) PropertyFloat (0171 + custom) 0x0004 FloatStatsTable
PropertyString ushort WString16L PropertyString (052 + custom) 0x0008 StringStatsTable
PropertyDataId ushort uint32 (DID) PropertyDataId (061 + custom) 0x1000 DidStatsTable
PropertyInstanceId ushort uint32 (guid) PropertyInstanceId (045 + 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
 68 UponResurrection 22 DamageVariance   87 ItemEfficiency
1319 ArmorMod vs (Slash, Pierce, Bludgeon, Cold, Fire, Acid, Electric)
20 CombatSpeed      21 WeaponLength      22 DamageVariance
6475 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 23 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.BuildPropertiesSuccess = 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):

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:

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.

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
0100 % normal 1.0
100200 % "burdened" 1.0 → 0.0 linearly
200300 % "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:18531858 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:

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 / RawSkillSkill.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. LuminancePropertyInt64.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 287289), 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

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

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

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)

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.