ModernUO/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md
Kamron Batman e07416902a
feat: derive the Running bit from the step pace and fix step-pacing bursts (#2599)
Stacked on #2594. Fixes jerky creature movement (lich / Fast-bucket melee chases) by choosing the client animation flag from the actual step pace instead of a caller-supplied `run` argument, and fixes three step-pacing defects in the move budget found while verifying it with paired server/client traces.

### Why

The `Direction.Running` bit does nothing for creatures server-side (`Mobile.OnMove` reads it only for the player throttle and stealth reveal). Its whole effect is on the client, which animates each step over a fixed time selected by that bit: walk 400 ms / run 200 ms on foot, 200 / 100 ms mounted. ClassicUO queues up to 5 steps and *drops* the sixth, so a creature stepping every 300 ms while flagged as walking backs the queue up until it snaps forward — the observed jerk.

The `run` argument never carried the one fact that matters (the step interval). RunUO passed `true` in combat / `false` for pets and gated it on `dist > 5`; #2271 flipped every combat site to `false`; pets passed `currentDistance > 2`. None of that is a coherent signal.

### What

**Pace-derived run flag**
- `BaseAI.ShouldRun()`: run iff the effective step delay (move clock + badly-hurt inflation) is shorter than `Movement.WalkFootDelay` / `WalkMountDelay` (mounted or flying) — with a continuity rule: an *isolated* step (taken after standing at least a walk interval) goes out as a walk, because the client renders each step alone and a lone run-flagged step is a 200 ms dart. Only a continuing cadence flags run; a true sprinter (pace under the run interpolation) always runs, since a walk-rendered first step would flood the client's 5-step queue. This reproduces RunUO's close-in feel (its `dist > 5` gate) from first principles.
- `DoMoveImpl` stamps the bit; it is the single place the flag is set.
- `run` removed from `MoveTo`, `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `MoveToWithGroup`, `MoveToWithCollisionAvoidance`, the move intent, and `PathFollower.Follow`. All 35 call sites updated. **API change** for custom scripts — documented in the RunUO migration docs (`09-items-mobiles-creatures.md`, `11-api-reference.md`) and `content-patterns.md` § Creature Speeds.

**Move-budget pacing fixes** (each confirmed by UTC-aligned server/client step traces)
- A stall no longer banks catch-up steps: the budget's snap-to-now released up to three steps in ~300 ms when a creature resumed chasing after standing beside its target — rendered as a teleport.
- Debt accrual removed entirely: a step landing sub-period late (think-grid vs budget misalignment during reactive mirroring) kept the remainder and fired a follow-up ~100 ms later — a dart pair. `ConsumeMoveBudget` now paces every step from when it was actually taken; in continuous pursuit the move-wake lands within wheel resolution of the deadline, so the cost is single-digit-ms drift.
- Net effect: a creature can never step faster than its pace, verified across a full chase session (zero sub-pace steps; metronomic 350 ms cadence for a 0.3 s lich).

- Test fixture now runs `Movement.Configure()` (the walk delays were 0 in tests).

### Accepted trade-off

Animal (LOW group) bodies without a run animation slide on their stand frames when flagged as running. Most are slow enough to stay flagged as walking; the client-side fallback is in ClassicUO/ClassicUO#1930.

### Tests

`RunFlagTests`: foot thresholds (0.3 / 0.125 run; 0.4 / 0.45 / 1.05 walk), flying uses the mount threshold, badly-hurt inflation flips a 0.35 s creature back to walk, a real `DoMove` stamps the bit, isolated steps drop to walk (sprinters keep running), a stall restarts the cadence with no banked steps, and a late step earns no quicker follow-up. Full suite: 837 Server + 747 UOContent green.
2026-08-30 16:48:52 -07:00

16 KiB

Items, Mobiles & Creatures Migration

Overview

Most RunUO migration work involves converting Item, Mobile, and BaseCreature subclasses. This doc combines all prior system changes (serialization, timers, property lists, naming) into complete step-by-step conversion guides for the most common content types.

Item Migration Step-by-Step

1. Apply Foundation Changes

  • File-scoped namespace
  • using ModernUO.Serialization;
  • Rename m_ fields to _camelCase
  • [Constructable][Constructible]
  • Replace Console.WriteLine with logging
  • Replace DateTime.UtcNow with Core.Now

2. Add Serialization Attributes

[SerializationGenerator(N, false)]  // N = old version + 1; false if old Deserialize used ReadInt()
public partial class MyItem : Item  // Add partial

3. Convert Fields to [SerializableField]

// RunUO
private int m_Charges;
[CommandProperty(AccessLevel.GameMaster)]
public int Charges { get { return m_Charges; } set { m_Charges = value; InvalidateProperties(); } }

// ModernUO
[SerializableField(0)]
[InvalidateProperties]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
// Property auto-generated with InvalidateProperties

4. Delete Boilerplate

  • Delete public MyItem(Serial serial) : base(serial) { }
  • Delete public override void Serialize(GenericWriter writer) { ... }
  • Delete public override void Deserialize(GenericReader reader) { ... }

5. Convert Timer Fields

// RunUO
private InternalTimer m_Timer;
// + nested Timer class

// ModernUO
private TimerExecutionToken _timerToken;
// + direct Timer.StartTimer() calls
// + [AfterDeserialization] for timer restoration
// + OnAfterDelete() for timer cancellation

6. Convert GetProperties

// RunUO
public override void GetProperties(ObjectPropertyList list)
{
    base.GetProperties(list);
    list.Add(1060741, m_Charges.ToString());
}

// ModernUO
public override void GetProperties(IPropertyList list)
{
    base.GetProperties(list);
    list.Add(1060741, $"{_charges}");
}

7. Convert Context Menus (if present)

// RunUO
public override void GetContextMenuEntries(Mobile from, List<ContextMenuEntry> list)
{
    base.GetContextMenuEntries(from, list);
    list.Add(new MyEntry(this));
}

// ModernUO
public override void GetContextMenuEntries(Mobile from, ref PooledRefList<ContextMenuEntry> list)
{
    base.GetContextMenuEntries(from, ref list);
    list.Add(new MyEntry(this));
}

Complete Before/After: Item

RunUO:

using System;
using Server;
using Server.Network;

namespace Server.Items
{
    public class MagicLantern : Item
    {
        private int m_Charges;
        private Mobile m_Owner;
        private InternalTimer m_Timer;

        [CommandProperty(AccessLevel.GameMaster)]
        public int Charges
        {
            get { return m_Charges; }
            set { m_Charges = value; InvalidateProperties(); }
        }

        [CommandProperty(AccessLevel.GameMaster)]
        public Mobile Owner
        {
            get { return m_Owner; }
            set { m_Owner = value; }
        }

        [Constructable]
        public MagicLantern() : base(0xA25)
        {
            m_Charges = Utility.RandomMinMax(5, 15);
            Weight = 2.0;
            Light = LightType.Circle300;
            Name = "a magic lantern";

            m_Timer = new InternalTimer(this);
            m_Timer.Start();
        }

        public MagicLantern(Serial serial) : base(serial) { }

        public override void OnDelete()
        {
            if (m_Timer != null)
                m_Timer.Stop();

            base.OnDelete();
        }

        public override void GetProperties(ObjectPropertyList list)
        {
            base.GetProperties(list);
            list.Add(1060741, m_Charges.ToString());
        }

        public override void OnDoubleClick(Mobile from)
        {
            if (!IsChildOf(from.Backpack))
            {
                from.SendLocalizedMessage(1042001);
                return;
            }

            if (m_Charges <= 0)
            {
                from.SendMessage("The lantern is depleted.");
                return;
            }

            m_Charges--;
            InvalidateProperties();
            from.SendMessage("The lantern flares brightly!");
        }

        public override void Serialize(GenericWriter writer)
        {
            base.Serialize(writer);
            writer.Write((int)1); // version

            writer.Write(m_Owner);
            writer.Write(m_Charges);
        }

        public override void Deserialize(GenericReader reader)
        {
            base.Deserialize(reader);
            int version = reader.ReadInt();

            switch (version)
            {
                case 1:
                    m_Owner = reader.ReadMobile();
                    goto case 0;
                case 0:
                    m_Charges = reader.ReadInt();
                    break;
            }

            m_Timer = new InternalTimer(this);
            m_Timer.Start();
        }

        private void Glow()
        {
            if (m_Charges > 0)
                Effects.SendLocationParticles(this, 0x376A, 9, 10, 5042);
        }

        private class InternalTimer : Timer
        {
            private MagicLantern m_Lantern;

            public InternalTimer(MagicLantern lantern) : base(TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3))
            {
                m_Lantern = lantern;
                Priority = TimerPriority.OneSecond;
            }

            protected override void OnTick()
            {
                m_Lantern.Glow();
            }
        }
    }
}

ModernUO:

using ModernUO.Serialization;

namespace Server.Items;

[SerializationGenerator(0)]
public partial class MagicLantern : Item
{
    [SerializableField(0)]
    [InvalidateProperties]
    [SerializedCommandProperty(AccessLevel.GameMaster)]
    private int _charges;

    [SerializableField(1)]
    [SerializedCommandProperty(AccessLevel.GameMaster)]
    private Mobile _owner;

    private TimerExecutionToken _glowTimer;

    [Constructible]
    public MagicLantern() : base(0xA25)
    {
        _charges = Utility.RandomMinMax(5, 15);
        Weight = 2.0;
        Light = LightType.Circle300;
        StartGlow();
    }

    public override string DefaultName => "a magic lantern";

    private void StartGlow()
    {
        Timer.StartTimer(TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3), Glow, out _glowTimer);
    }

    [AfterDeserialization]
    private void AfterDeserialization() => StartGlow();

    public override void OnAfterDelete()
    {
        _glowTimer.Cancel();
        base.OnAfterDelete();
    }

    public override void GetProperties(IPropertyList list)
    {
        base.GetProperties(list);
        list.Add(1060741, $"{_charges}");
    }

    public override void OnDoubleClick(Mobile from)
    {
        if (!IsChildOf(from.Backpack))
        {
            from.SendLocalizedMessage(1042001);
            return;
        }

        if (_charges <= 0)
        {
            from.SendMessage("The lantern is depleted.");
            return;
        }

        Charges--;
        from.SendMessage("The lantern flares brightly!");
    }

    private void Glow()
    {
        if (_charges > 0)
            Effects.SendLocationParticles(this, 0x376A, 9, 10, 5042);
    }
}

What changed:

  • File-scoped namespace
  • partial class + [SerializationGenerator(0)] (omit encoded parameter)
  • [Constructable][Constructible]
  • m_Charges/m_Owner_charges/_owner with [SerializableField]
  • Manual properties → auto-generated with [SerializedCommandProperty]
  • InvalidateProperties() in setter → [InvalidateProperties] attribute
  • Name = "..."DefaultName => property override
  • Serial constructor deleted
  • Serialize/Deserialize deleted
  • Nested InternalTimer class → Timer.StartTimer() + TimerExecutionToken
  • Timer in Deserialize → [AfterDeserialization]
  • OnDelete() timer stop → OnAfterDelete() + _token.Cancel()
  • ObjectPropertyListIPropertyList
  • GetProperties uses string interpolation with holes

BaseCreature Migration

BaseCreature subclasses follow the same pattern as items but have additional considerations.

Key Differences from Items

  1. Constructor calls base(AIType, FightMode) instead of base(itemID)
  2. Stats set with SetStr(), SetDex(), SetInt(), etc.
  3. Damage/resistance types set explicitly
  4. GenerateLoot() override for loot tables
  5. Many property overrides (CorpseName, Meat, Hides, etc.)

Before/After: Simple Creature

RunUO:

namespace Server.Mobiles
{
    [CorpseName("a wolf corpse")]
    public class ForestWolf : BaseCreature
    {
        [Constructable]
        public ForestWolf() : base(AIType.AI_Melee, FightMode.Closest, 10, 1, 0.2, 0.4)
        {
            Name = "a forest wolf";
            Body = 225;
            BaseSoundID = 0xE5;

            SetStr(80, 120);
            SetDex(90, 110);
            SetInt(20, 40);

            SetHits(60, 80);

            SetDamage(8, 14);

            SetDamageType(ResistanceType.Physical, 100);

            SetResistance(ResistanceType.Physical, 25, 35);

            SetSkill(SkillName.MagicResist, 30.0, 50.0);
            SetSkill(SkillName.Tactics, 50.0, 70.0);
            SetSkill(SkillName.Wrestling, 50.0, 70.0);

            Fame = 600;
            Karma = 0;

            VirtualArmor = 28;

            Tamable = true;
            ControlSlots = 1;
            MinTameSkill = 50.1;
        }

        public ForestWolf(Serial serial) : base(serial) { }

        public override int Meat { get { return 1; } }
        public override int Hides { get { return 6; } }
        public override FoodType FavoriteFood { get { return FoodType.Meat; } }
        public override PackInstinct PackInstinct { get { return PackInstinct.Canine; } }

        public override void GenerateLoot()
        {
            AddLoot(LootPack.Meager);
        }

        public override void Serialize(GenericWriter writer)
        {
            base.Serialize(writer);
            writer.Write((int)0);
        }

        public override void Deserialize(GenericReader reader)
        {
            base.Deserialize(reader);
            int version = reader.ReadInt();
        }
    }
}

ModernUO:

using ModernUO.Serialization;

namespace Server.Mobiles;

[SerializationGenerator(0)]
public partial class ForestWolf : BaseCreature
{
    [Constructible]
    public ForestWolf() : base(AIType.AI_Melee, FightMode.Closest)
    {
        Body = 225;
        BaseSoundID = 0xE5;

        SetStr(80, 120);
        SetDex(90, 110);
        SetInt(20, 40);

        SetHits(60, 80);

        SetDamage(8, 14);

        SetDamageType(ResistanceType.Physical, 100);

        SetResistance(ResistanceType.Physical, 25, 35);

        SetSkill(SkillName.MagicResist, 30.0, 50.0);
        SetSkill(SkillName.Tactics, 50.0, 70.0);
        SetSkill(SkillName.Wrestling, 50.0, 70.0);

        Fame = 600;
        Karma = 0;

        VirtualArmor = 28;

        Tamable = true;
        ControlSlots = 1;
        MinTameSkill = 50.1;
    }

    public override string CorpseName => "a wolf corpse";
    public override string DefaultName => "a forest wolf";
    public override int Meat => 1;
    public override int Hides => 6;
    public override FoodType FavoriteFood => FoodType.Meat;
    public override PackInstinct PackInstinct => PackInstinct.Canine;

    public override void GenerateLoot()
    {
        AddLoot(LootPack.Meager);
    }
}

What changed:

  • [CorpseName("...")] attribute → CorpseName property override
  • Name = "..."DefaultName property override
  • BaseCreature(AI, Fight, 10, 1, 0.2, 0.4)BaseCreature(AI, Fight) (extra params have defaults)
  • Expression-bodied property overrides
  • Serialization boilerplate removed
  • Serial constructor removed

Key Creature Constructor Differences

// RunUO — many parameters
public ForestWolf() : base(AIType.AI_Melee, FightMode.Closest, 10, 1, 0.2, 0.4)
// 10 = RangePerception, 1 = RangeFight, 0.2 = ActiveSpeed, 0.4 = PassiveSpeed

// ModernUO — simplified (defaults built in)
public ForestWolf() : base(AIType.AI_Melee, FightMode.Closest)

The extra parameters (RangePerception, RangeFight, ActiveSpeed, PassiveSpeed) have sensible defaults. Only specify them if they differ from defaults.

Common Creature Attribute Changes

RunUO ModernUO
[CorpseName("a corpse")] attribute public override string CorpseName => "a corpse";
Name = "a creature" in constructor public override string DefaultName => "a creature";
get { return value; } => value; expression-bodied

AI Movement: No run Argument

RunUO's movement calls took a run flag that callers set inconsistently (true in combat, false for pets, gated by dist > 5 inside MoveTo). The flag only selects the client's per-step animation time, so ModernUO derives it from the creature's step pace (BaseAI.ShouldRun) and the parameter is gone:

// RunUO
MoveTo(combatant, true, m_Mobile.RangeFight);
WalkMobileRange(m_Mobile.ControlMaster, 1, false, 0, 1);

// ModernUO
MoveTo(combatant, Mobile.RangeFight);
WalkMobileRange(Mobile.ControlMaster, 1, 0, 1);

ApproachTarget, MoveToPoint and PathFollower.Follow lose the argument the same way. To make a creature run, make it fast (SetMoveSpeed / npc-speeds.json), not flagged. An isolated step (after the creature stood for at least a walk interval) goes out as a walk regardless of pace — only a continuing cadence, or a pace faster than the run interpolation, flags run.

Item Name Changes

// RunUO
Name = "a magic gem";  // Set in constructor

// ModernUO — prefer property overrides
public override string DefaultName => "a magic gem";
// OR for cliloc:
public override int LabelNumber => 1234567;

Equipment/Weapon/Armor Migration

Weapons and armor follow the same item pattern but inherit from specialized base classes:

// ModernUO weapon example
[SerializationGenerator(0)]
public partial class MySpecialSword : BaseSword
{
    [Constructible]
    public MySpecialSword() : base(0x13FF) // Katana graphic
    {
        Weight = 6.0;
        Layer = Layer.TwoHanded;
    }

    public override string DefaultName => "a special sword";
    public override int AosStrengthReq => 25;
    public override int AosMinDamage => 11;
    public override int AosMaxDamage => 13;
    public override int AosSpeed => 44;
    public override float MlSpeed => 2.50f;
}

Common Base Classes

RunUO ModernUO Notes
BaseWeapon BaseWeapon Same, add partial
BaseSword / BaseMace / etc. Same Same, add partial
BaseArmor BaseArmor Same, add partial
BaseClothing BaseClothing Same, add partial
BaseJewel BaseJewel Same, add partial
BaseContainer BaseContainer Same, add partial
Food Food Same, add partial
BasePotion BasePotion Same, add partial

Edge Cases & Gotchas

1. [TypeAlias] for Save Compatibility

If a class changed namespace or name, use [TypeAlias]:

[TypeAlias("Server.Items.OldName")]
[SerializationGenerator(0)]
public partial class NewName : Item { }

2. OnDoubleClick Validation

ModernUO patterns prefer:

if (!IsChildOf(from.Backpack))
{
    from.SendLocalizedMessage(1042001);
    return;
}

3. CorpseName as Property Override

RunUO uses [CorpseName] attribute. ModernUO uses a property override instead.

4. SetMana(0) for Non-Casters

Always call SetMana(0) for creatures that shouldn't have mana.

5. Decrement via Generated Property

Use the generated property name (PascalCase) when decrementing to trigger dirty tracking:

Charges--;  // Uses generated property — triggers MarkDirty + InvalidateProperties
// NOT: _charges--;  // Bypasses tracking

See Also

  • dev-docs/content-patterns.md — ModernUO content creation patterns
  • dev-docs/serialization.md — Serialization system
  • 02-serialization.md — Serialization migration details
  • 03-timers.md — Timer migration
  • 06-property-lists.md — Property list migration