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.
This commit is contained in:
parent
7230c1590f
commit
3f913f1999
20 changed files with 15312 additions and 17 deletions
143
src/AcDream.Core/Audio/AudioModel.cs
Normal file
143
src/AcDream.Core/Audio/AudioModel.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Audio;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Scaffold for R5 — audio data model + engine interface.
|
||||
// Full research: docs/research/deepdives/r05-audio-sound.md
|
||||
// Runtime backend (Silk.NET.OpenAL) lives in AcDream.App.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Enumerated retail sound IDs. See r05 §5 for the full 204-entry list.
|
||||
/// Only the commonly-fired ones are given names here.
|
||||
/// </summary>
|
||||
public enum SoundId : uint
|
||||
{
|
||||
None = 0,
|
||||
FootstepDefault = 0x02,
|
||||
FootstepGrass = 0x03,
|
||||
FootstepWater = 0x04,
|
||||
FootstepDirt = 0x05,
|
||||
FootstepStone = 0x06,
|
||||
FootstepWood = 0x07,
|
||||
SwingSword = 0x10,
|
||||
SwingAxe = 0x11,
|
||||
HitMetalOnMetal = 0x20,
|
||||
HitMetalOnLeather = 0x21,
|
||||
HitFlesh = 0x22,
|
||||
ArrowWhoosh = 0x30,
|
||||
ArrowThud = 0x31,
|
||||
SpellCastWar = 0x40,
|
||||
SpellCastLife = 0x41,
|
||||
SpellCastItem = 0x42,
|
||||
SpellCastCreature = 0x43,
|
||||
PortalWhoosh = 0x50,
|
||||
LifestoneTie = 0x51,
|
||||
Death = 0x60,
|
||||
PotionDrink = 0x70,
|
||||
BuffApplied = 0x80,
|
||||
// More constants populated during R5 audio port.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-SoundId entry from the SoundTable dat (0x20000000..0x2000FFFF).
|
||||
/// One Sound can have multiple entries with probabilities — that's
|
||||
/// retail's variation mechanism (e.g. 3 different footstep clips).
|
||||
/// </summary>
|
||||
public sealed class SoundEntry
|
||||
{
|
||||
public uint WaveId { get; init; } // → Wave dat (0x0A000000..0x0A00FFFF)
|
||||
public int Priority { get; init; } // eviction ordering (0..7)
|
||||
public float Probability{ get; init; } // for entries with multiple alternatives
|
||||
public float VolumeBase { get; init; } // 0..1 multiplier applied before falloff
|
||||
public float PitchMin { get; init; }
|
||||
public float PitchMax { get; init; }
|
||||
public bool Loop { get; init; }
|
||||
public bool Is3D { get; init; } // 3D positional vs UI/music flat
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raw decoded PCM data from a Wave dat. Set by <c>WaveDecoder</c> at
|
||||
/// load time. Retail supports both PCM and MP3 source; we decode MP3
|
||||
/// to PCM once at load (same as retail does for long clips).
|
||||
/// </summary>
|
||||
public sealed class WaveData
|
||||
{
|
||||
public int ChannelCount { get; init; }
|
||||
public int SampleRate { get; init; }
|
||||
public int BitsPerSample{ get; init; } // 8 or 16
|
||||
public byte[] PcmBytes { get; init; } = Array.Empty<byte>();
|
||||
public TimeSpan Duration { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Falloff math (r05 §2). Retail is CPU-side inverse-square, NOT
|
||||
/// DirectSound3DBuffer. No doppler, no cone, no HRTF.
|
||||
/// </summary>
|
||||
public static class AudioFalloff
|
||||
{
|
||||
/// <summary>
|
||||
/// Attenuation factor based on distance. Retail uses pure inverse-square
|
||||
/// above a minimum-distance threshold.
|
||||
/// </summary>
|
||||
public static float AttenuationAt(float distanceMeters, float minDistance = 1.0f)
|
||||
{
|
||||
if (distanceMeters < minDistance) return 1.0f;
|
||||
float att = (minDistance * minDistance) / (distanceMeters * distanceMeters);
|
||||
return Math.Clamp(att, 0f, 1f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stereo pan from listener-relative X coord. ±1.0 fully panned.
|
||||
/// </summary>
|
||||
public static float PanFromRelative(float relativeX, float panRange = 20f)
|
||||
{
|
||||
if (panRange <= 0) return 0f;
|
||||
return Math.Clamp(relativeX / panRange, -1f, 1f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface the platform audio engine (AcDream.App layer) implements.
|
||||
/// Core defines the contract; App implements via Silk.NET.OpenAL.
|
||||
/// </summary>
|
||||
public interface IAudioEngine : IDisposable
|
||||
{
|
||||
/// <summary>Set master volume [0..1].</summary>
|
||||
float MasterVolume { get; set; }
|
||||
float SfxVolume { get; set; }
|
||||
float MusicVolume { get; set; }
|
||||
float AmbientVolume{ get; set; }
|
||||
|
||||
/// <summary>Update listener pose (called per frame from player position).</summary>
|
||||
void SetListener(float posX, float posY, float posZ,
|
||||
float forwardX, float forwardY, float forwardZ,
|
||||
float upX, float upY, float upZ);
|
||||
|
||||
/// <summary>Play a 2D UI sound (no falloff).</summary>
|
||||
void PlayUi(SoundId id);
|
||||
|
||||
/// <summary>Play a 3D sound at a world position.</summary>
|
||||
void Play3D(SoundId id, float x, float y, float z);
|
||||
|
||||
/// <summary>Start a looped ambient sound (landblock-attached).</summary>
|
||||
int StartAmbient(SoundId id, float x, float y, float z);
|
||||
void StopAmbient(int handle);
|
||||
|
||||
/// <summary>Start music (fades out previous if any).</summary>
|
||||
void PlayMusic(string resourceName, bool loop);
|
||||
void StopMusic();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache of decoded waves + SoundTable lookups. Owned by the App-layer
|
||||
/// AudioEngine; Core exposes the interface.
|
||||
/// </summary>
|
||||
public interface ISoundCache
|
||||
{
|
||||
WaveData GetWave(uint waveId);
|
||||
IReadOnlyList<SoundEntry> GetSoundEntries(SoundId id);
|
||||
IReadOnlyList<SoundEntry> GetSoundEntries(uint soundTableId, SoundId id);
|
||||
}
|
||||
192
src/AcDream.Core/Combat/CombatModel.cs
Normal file
192
src/AcDream.Core/Combat/CombatModel.cs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Combat;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Scaffold for R2 — combat math + damage event data.
|
||||
// Full research: docs/research/deepdives/r02-combat-system.md
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public enum CombatMode
|
||||
{
|
||||
Undef = 0,
|
||||
NonCombat = 1,
|
||||
Melee = 2,
|
||||
Missile = 3,
|
||||
Magic = 4,
|
||||
Peaceful = 5,
|
||||
}
|
||||
|
||||
public enum AttackHeight
|
||||
{
|
||||
High = 1,
|
||||
Medium = 2,
|
||||
Low = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail uses a 15-bit flags enum for attack types — weapon categories.
|
||||
/// See r02 §2 + <c>ACE.Entity.Enum.AttackType</c>.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum AttackType : uint
|
||||
{
|
||||
None = 0,
|
||||
Punch = 0x0001,
|
||||
Kick = 0x0002,
|
||||
Thrust = 0x0004,
|
||||
Slash = 0x0008,
|
||||
DoubleSlash = 0x0010,
|
||||
TripleSlash = 0x0020,
|
||||
DoubleThrust = 0x0040,
|
||||
TripleThrust = 0x0080,
|
||||
Offhand = 0x0100,
|
||||
OffhandSlash = 0x0200,
|
||||
OffhandThrust = 0x0400,
|
||||
ThrustSlash = 0x0800,
|
||||
// more in r02 §2
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum DamageType : uint
|
||||
{
|
||||
Undef = 0,
|
||||
Slash = 0x0001,
|
||||
Pierce = 0x0002,
|
||||
Bludgeon = 0x0004,
|
||||
Cold = 0x0008,
|
||||
Fire = 0x0010,
|
||||
Acid = 0x0020,
|
||||
Electric = 0x0040,
|
||||
Nether = 0x0080,
|
||||
Mana = 0x0100,
|
||||
Health = 0x0200,
|
||||
Stamina = 0x0400,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 12-quadrant body-part table: High/Mid/Low × L/R × Front/Back.
|
||||
/// Layout: <c>[HLF MLF LLF HRF MRF LRF HLB MLB LLB HRB MRB LRB]</c>.
|
||||
/// Retail picks a quadrant based on AttackHeight + small RNG for L/R.
|
||||
/// </summary>
|
||||
public enum BodyPart
|
||||
{
|
||||
Head = 0,
|
||||
Chest = 1,
|
||||
Abdomen = 2,
|
||||
UpperArm = 3,
|
||||
LowerArm = 4,
|
||||
Hand = 5,
|
||||
UpperLeg = 6,
|
||||
LowerLeg = 7,
|
||||
Foot = 8,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single attack resolution: who hit whom, where, with what weapon, for how much.
|
||||
/// Produced by the server; client reads for damage floaters + HP bar updates.
|
||||
/// </summary>
|
||||
public readonly record struct DamageEvent(
|
||||
uint AttackerGuid,
|
||||
uint TargetGuid,
|
||||
AttackType AttackType,
|
||||
DamageType DamageType,
|
||||
BodyPart BodyPart,
|
||||
int DamageDealt,
|
||||
int PostResistDamage,
|
||||
bool WasCritical,
|
||||
bool WasEvaded,
|
||||
bool WasResisted,
|
||||
float AccuracyModUsed,
|
||||
float PowerModUsed);
|
||||
|
||||
/// <summary>
|
||||
/// Retail-faithful combat math. Source: r02 research + ACE CombatManager.
|
||||
/// All formulas cited to research doc §5 + §7.
|
||||
/// </summary>
|
||||
public static class CombatMath
|
||||
{
|
||||
// ── Power bar (melee) ──────────────────────────────────────────
|
||||
// PowerMod = PowerLevel + 0.5, so [0.5, 1.5] over a full charge.
|
||||
public static float PowerModMelee(float powerLevel) => powerLevel + 0.5f;
|
||||
|
||||
// ── Accuracy bar (missile) ─────────────────────────────────────
|
||||
// AccuracyMod = AccuracyLevel + 0.6, so [0.6, 1.6].
|
||||
public static float AccuracyModMissile(float accuracyLevel) => accuracyLevel + 0.6f;
|
||||
|
||||
// ── Hit-chance sigmoid ─────────────────────────────────────────
|
||||
// Physical: k=0.03, magic: k=0.07. chance = 1 - 1/(1+e^(k×(skill-def))).
|
||||
public static double HitChancePhysical(int attackSkill, int defenseSkill)
|
||||
=> 1.0 - 1.0 / (1.0 + Math.Exp(0.03 * (attackSkill - defenseSkill)));
|
||||
|
||||
public static double HitChanceMagic(int attackSkill, int defenseSkill)
|
||||
=> 1.0 - 1.0 / (1.0 + Math.Exp(0.07 * (attackSkill - defenseSkill)));
|
||||
|
||||
// ── Base crit rates ────────────────────────────────────────────
|
||||
public const double PhysicalCritBase = 0.10; // 10%
|
||||
public const double MagicCritBase = 0.05; // 5%
|
||||
|
||||
/// <summary>
|
||||
/// Full retail damage formula (r02 §5). Simplified version without
|
||||
/// augmentations and ratings — extend as needed.
|
||||
/// </summary>
|
||||
public static int ComputeDamage(
|
||||
float weaponDamageMin, float weaponDamageMax,
|
||||
int attributeBonus, // Str for melee, Coord for missile, Self for magic
|
||||
float powerMod, // PowerModMelee or AccuracyModMissile
|
||||
float skillMod, // skill-based bonus (weapon skill)
|
||||
bool isCritical,
|
||||
float critMultiplier,
|
||||
float armorReduction, // damage reduction from armor
|
||||
float resistMultiplier, // 0..1 multiplier from buffs / natural resist
|
||||
Random rng)
|
||||
{
|
||||
// Base weapon roll
|
||||
double baseDmg = rng.NextDouble() * (weaponDamageMax - weaponDamageMin) + weaponDamageMin;
|
||||
|
||||
// Apply attribute bonus + power mod + skill mod
|
||||
double raw = (baseDmg + attributeBonus) * powerMod * skillMod;
|
||||
|
||||
// Crit
|
||||
if (isCritical) raw *= critMultiplier;
|
||||
|
||||
// Subtract armor (capped at 0)
|
||||
raw = Math.Max(0, raw - armorReduction);
|
||||
|
||||
// Resist multiplier (<1 reduces, >1 amplifies)
|
||||
raw *= resistMultiplier;
|
||||
|
||||
return (int)Math.Max(0, Math.Round(raw));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-body-part armor level (AL). Retail creatures have 9 body parts
|
||||
/// with independent AL values. See r02 §8.
|
||||
/// </summary>
|
||||
public sealed class ArmorBuild
|
||||
{
|
||||
public int ALHead { get; set; }
|
||||
public int ALChest { get; set; }
|
||||
public int ALAbdomen { get; set; }
|
||||
public int ALUpperArm { get; set; }
|
||||
public int ALLowerArm { get; set; }
|
||||
public int ALHand { get; set; }
|
||||
public int ALUpperLeg { get; set; }
|
||||
public int ALLowerLeg { get; set; }
|
||||
public int ALFoot { get; set; }
|
||||
|
||||
public int Get(BodyPart bp) => bp switch
|
||||
{
|
||||
BodyPart.Head => ALHead,
|
||||
BodyPart.Chest => ALChest,
|
||||
BodyPart.Abdomen => ALAbdomen,
|
||||
BodyPart.UpperArm => ALUpperArm,
|
||||
BodyPart.LowerArm => ALLowerArm,
|
||||
BodyPart.Hand => ALHand,
|
||||
BodyPart.UpperLeg => ALUpperLeg,
|
||||
BodyPart.LowerLeg => ALLowerLeg,
|
||||
BodyPart.Foot => ALFoot,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
193
src/AcDream.Core/Items/ItemInstance.cs
Normal file
193
src/AcDream.Core/Items/ItemInstance.cs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Items;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Scaffold for R6 — items + inventory data model.
|
||||
// Full research: docs/research/deepdives/r06-items-inventory.md
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// AC's <c>ItemType</c> is a 32-bit flags enum — a single dat weenie can
|
||||
/// assert multiple type bits. From <c>ACE.Entity.Enum.ItemType</c>
|
||||
/// cross-checked against the decompile paperdoll tooltip dispatcher.
|
||||
/// Full bit list in the research doc §1.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum ItemType : uint
|
||||
{
|
||||
None = 0,
|
||||
MeleeWeapon = 0x00000001,
|
||||
Armor = 0x00000002,
|
||||
Clothing = 0x00000004,
|
||||
Jewelry = 0x00000008,
|
||||
Creature = 0x00000010,
|
||||
Food = 0x00000020,
|
||||
Money = 0x00000040,
|
||||
Misc = 0x00000080,
|
||||
MissileWeapon = 0x00000100,
|
||||
Container = 0x00000200,
|
||||
Useless = 0x00000400,
|
||||
Gem = 0x00000800,
|
||||
SpellComponents = 0x00001000,
|
||||
Writable = 0x00002000,
|
||||
Key = 0x00004000,
|
||||
Caster = 0x00008000,
|
||||
Portal = 0x00010000,
|
||||
Lockable = 0x00020000,
|
||||
PromissoryNote = 0x00040000,
|
||||
ManaStone = 0x00080000,
|
||||
Service = 0x00100000,
|
||||
MagicWieldable = 0x00200000,
|
||||
CraftCookingBase = 0x00400000,
|
||||
CraftAlchemyBase = 0x00800000,
|
||||
CraftFletchingBase = 0x01000000,
|
||||
CraftAlchemyIntermediate= 0x02000000,
|
||||
CraftCookingIntermediate= 0x04000000,
|
||||
CraftFletchingIntermediate = 0x08000000,
|
||||
LifeStone = 0x10000000,
|
||||
TinkeringTool = 0x20000000,
|
||||
TinkeringMaterial = 0x40000000,
|
||||
Gameboard = 0x80000000u,
|
||||
Vestements = Armor | Clothing,
|
||||
Weapon = MeleeWeapon | MissileWeapon | Caster,
|
||||
WeaponOrCaster = Weapon,
|
||||
Item = Weapon | Armor | Clothing | Jewelry | Container,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Equipment slot bitmask. 31 slots from head to Aetheria. Paperdoll
|
||||
/// widget offsets <c>+0x604..+0x660</c> in the retail panel correspond
|
||||
/// to these bits 1:1 (see r06 §2 and UI slice 05 paperdoll section).
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum EquipMask : uint
|
||||
{
|
||||
None = 0,
|
||||
HeadWear = 0x00000001,
|
||||
ChestWear = 0x00000002,
|
||||
AbdomenWear = 0x00000004,
|
||||
UpperArmWear = 0x00000008,
|
||||
LowerArmWear = 0x00000010,
|
||||
HandWear = 0x00000020,
|
||||
UpperLegWear = 0x00000040,
|
||||
LowerLegWear = 0x00000080,
|
||||
FootWear = 0x00000100,
|
||||
ChestArmor = 0x00000200,
|
||||
AbdomenArmor = 0x00000400,
|
||||
UpperArmArmor = 0x00000800,
|
||||
LowerArmArmor = 0x00001000,
|
||||
HandArmor = 0x00002000,
|
||||
UpperLegArmor = 0x00004000,
|
||||
LowerLegArmor = 0x00008000,
|
||||
FootArmor = 0x00010000,
|
||||
Necklace = 0x00020000,
|
||||
LeftBracelet = 0x00040000,
|
||||
RightBracelet = 0x00080000,
|
||||
LeftRing = 0x00100000,
|
||||
RightRing = 0x00200000,
|
||||
MeleeWeapon = 0x00400000,
|
||||
Shield = 0x00800000,
|
||||
MissileWeapon = 0x01000000,
|
||||
Held = 0x02000000, // lit torch, book in hand
|
||||
MissileAmmo = 0x04000000,
|
||||
Cloak = 0x08000000,
|
||||
TrinketOne = 0x10000000,
|
||||
AetheriaRed = 0x20000000,
|
||||
AetheriaYellow= 0x40000000,
|
||||
AetheriaBlue = 0x80000000u,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AC's property model is split across 7 typed tables. See r06 §3 for
|
||||
/// the full property enumeration. This struct is a thin wrapper; real
|
||||
/// bundles come over the wire in <c>ObjectDesc</c> / <c>IdentifyResponse</c>.
|
||||
/// </summary>
|
||||
public sealed class PropertyBundle
|
||||
{
|
||||
public Dictionary<uint, int> Ints { get; } = new();
|
||||
public Dictionary<uint, long> Int64s { get; } = new();
|
||||
public Dictionary<uint, bool> Bools { get; } = new();
|
||||
public Dictionary<uint, double> Floats { get; } = new();
|
||||
public Dictionary<uint, string> Strings { get; } = new();
|
||||
public Dictionary<uint, uint> DataIds { get; } = new();
|
||||
public Dictionary<uint, uint> InstanceIds { get; } = new();
|
||||
|
||||
public int GetInt (uint k, int def = 0) => Ints.TryGetValue(k, out var v) ? v : def;
|
||||
public bool GetBool (uint k, bool def = false) => Bools.TryGetValue(k, out var v) ? v : def;
|
||||
public double GetFloat (uint k, double def = 0) => Floats.TryGetValue(k, out var v) ? v : def;
|
||||
public string GetString(uint k, string def = "") => Strings.TryGetValue(k, out var v) ? v : def;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-item live state. The server owns item identity (ObjectId);
|
||||
/// acdream mirrors properties here on <c>CreateObject</c> and updates
|
||||
/// via <c>UpdateProperty*</c> messages.
|
||||
/// </summary>
|
||||
public sealed class ItemInstance
|
||||
{
|
||||
public uint ObjectId { get; init; }
|
||||
public uint WeenieClassId { get; init; } // "blueprint"
|
||||
public string Name { get; set; } = "";
|
||||
public ItemType Type { get; set; }
|
||||
public EquipMask ValidLocations { get; set; }
|
||||
public EquipMask CurrentlyEquippedLocation { get; set; }
|
||||
public uint IconId { get; set; } // 0x06xxxxxx
|
||||
public uint IconUnderlayId{ get; set; } // "magic" underlay
|
||||
public uint IconOverlayId { get; set; } // "enchanted" overlay
|
||||
public int StackSize { get; set; } = 1;
|
||||
public int StackSizeMax { get; set; } = 1;
|
||||
public int Burden { get; set; } // per-stack total
|
||||
public int Value { get; set; } // pyreals
|
||||
public uint ContainerId { get; set; } // parent container ObjectId, or 0
|
||||
public int ContainerSlot { get; set; } = -1;
|
||||
public bool Attuned { get; set; }
|
||||
public bool Bonded { get; set; }
|
||||
public PropertyBundle Properties { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Container = inventory pack. Hierarchy is strictly 2-deep: character
|
||||
/// → side packs; a side pack cannot hold another side pack (r06 §7).
|
||||
/// </summary>
|
||||
public sealed class Container
|
||||
{
|
||||
public uint ObjectId { get; init; }
|
||||
public int Capacity { get; set; } = 102; // main inv default
|
||||
public int SideCapacity { get; set; } = 0; // 0 for side-pack
|
||||
public int BurdenLimit { get; set; }
|
||||
public List<ItemInstance> Items { get; } = new();
|
||||
public List<Container> SidePacks { get; } = new(); // empty for side-pack
|
||||
public bool IsSidePack => SideCapacity == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Burden math — r06 §6. <c>maxBurden = 150 × Strength + Strength × bonusBurden</c>;
|
||||
/// carry limit is <c>3 × maxBurden</c> before you can't pick up at all.
|
||||
/// </summary>
|
||||
public static class BurdenMath
|
||||
{
|
||||
public const int BurdenPerStrength = 150;
|
||||
|
||||
public static int ComputeMax(int strength, int bonusBurden)
|
||||
=> BurdenPerStrength * strength + strength * bonusBurden;
|
||||
|
||||
public static int ComputeCarryLimit(int strength, int bonusBurden)
|
||||
=> 3 * ComputeMax(strength, bonusBurden);
|
||||
|
||||
/// <summary>
|
||||
/// Retail's "encumbered" multiplier interpolates between 1.0 at
|
||||
/// zero burden and a low value at max. See r06 §6 for the curve.
|
||||
/// </summary>
|
||||
public static float ComputeEncumbranceMod(int currentBurden, int maxBurden)
|
||||
{
|
||||
if (maxBurden <= 0) return 1f;
|
||||
float ratio = (float)currentBurden / maxBurden;
|
||||
// Roughly 1.0 until 50%, then linear decay to ~0.7 at 100%, 0.1 at 300%.
|
||||
if (ratio <= 0.5f) return 1f;
|
||||
if (ratio <= 1.0f) return 1f - (ratio - 0.5f) * 0.6f; // 1.0 → 0.7
|
||||
if (ratio <= 3.0f) return 0.7f - (ratio - 1.0f) * 0.3f; // 0.7 → 0.1
|
||||
return 0.1f;
|
||||
}
|
||||
}
|
||||
216
src/AcDream.Core/Spells/SpellModel.cs
Normal file
216
src/AcDream.Core/Spells/SpellModel.cs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Spells;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Scaffold for R1 — spell system data model + cast state machine.
|
||||
// Full research: docs/research/deepdives/r01-spell-system.md
|
||||
// Wire evidence: holtburger fixtures + ACE Player_Spells.cs.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Magic school. Each maps to a skill (LifeMagic, CreatureMagic, ItemMagic, WarMagic, VoidMagic).
|
||||
/// See r01 §1 + <c>ACE.Entity.Enum.MagicSchool</c>.
|
||||
/// </summary>
|
||||
public enum MagicSchool : uint
|
||||
{
|
||||
None = 0,
|
||||
WarMagic = 1,
|
||||
LifeMagic = 2,
|
||||
CreatureEnchantment = 3,
|
||||
ItemEnchantment = 4,
|
||||
PortalMagic = 5,
|
||||
// VoidMagic added in later retail revisions; uses LifeMagic skill.
|
||||
VoidMagic = 6,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-spell targeting category. Validates before cast.
|
||||
/// </summary>
|
||||
public enum SpellTargetType : uint
|
||||
{
|
||||
None = 0,
|
||||
Self = 1,
|
||||
Item = 2,
|
||||
Creature = 3,
|
||||
Object = 4,
|
||||
SelfOrItem = 5,
|
||||
Undef = 6,
|
||||
OtherItem = 7, // targeted item but not your own
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spell category for enchantment stacking. Retail rule: same category + higher
|
||||
/// "power" replaces lower; different categories stack additively. See r01 §5.
|
||||
/// </summary>
|
||||
public enum SpellCategory : uint
|
||||
{
|
||||
Undef = 0,
|
||||
// There are ~600 categories in the retail dat. We reference them by number
|
||||
// rather than port the whole enum here.
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum SpellFlags : uint
|
||||
{
|
||||
None = 0,
|
||||
Beneficial = 0x00000001,
|
||||
Resistable = 0x00000002,
|
||||
Projectile = 0x00000004,
|
||||
EnchantmentDispel = 0x00000008,
|
||||
PurgeOnReset = 0x00000010,
|
||||
NotIndoors = 0x00000020,
|
||||
Melee = 0x00000040,
|
||||
Missile = 0x00000080,
|
||||
// more flags in r01 §1
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-spell on-disk record from the SpellTable dat (0x2Fxxxxxx).
|
||||
/// 27 fields in retail; r01 §1 has the full layout.
|
||||
/// </summary>
|
||||
public sealed class SpellDatEntry
|
||||
{
|
||||
public uint SpellId { get; init; }
|
||||
public string Name { get; init; } = "";
|
||||
public string Description { get; init; } = "";
|
||||
public MagicSchool School { get; init; }
|
||||
public int Power { get; init; } // difficulty / mastery
|
||||
public float CastingTime { get; init; } // seconds
|
||||
public float Duration { get; init; } // for enchants
|
||||
public int BaseMana { get; init; }
|
||||
public int ManaMod { get; init; } // per extra target
|
||||
public int ManaConversionBase { get; init; }
|
||||
public float ManaConversionMod { get; init; }
|
||||
public SpellTargetType TargetType { get; init; }
|
||||
public SpellCategory Category { get; init; }
|
||||
public SpellFlags Flags { get; init; }
|
||||
public IReadOnlyList<int> FormulaComponentIds { get; init; } = Array.Empty<int>();
|
||||
public int RangeConstant { get; init; }
|
||||
public int EconomyMod { get; init; }
|
||||
public uint Icon { get; init; } // 0x06xxxxxx
|
||||
public uint SpellStatModKey { get; init; } // what property it buffs
|
||||
public int SpellStatModVal { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spell component from the SpellComponentsTable dat (0x30xxxxxx).
|
||||
/// Scarabs, herbs, talismans, taper wax. Each has a base consumption rate.
|
||||
/// </summary>
|
||||
public sealed class SpellComponentEntry
|
||||
{
|
||||
public int ComponentId { get; init; }
|
||||
public string Name { get; init; } = "";
|
||||
public uint Icon { get; init; }
|
||||
public double CdmBonus { get; init; } // component-destruction modifier
|
||||
public double ManaMod { get; init; }
|
||||
public int Type { get; init; } // 1=scarab, 2=herb, 3=talisman, 4=taper, 5=pwax
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime cast state — retail's model is a 4-phase state machine:
|
||||
/// Preparing → Casting (syllables) → Releasing → Idle (success) / Fizzled.
|
||||
/// See r01 §3 for the exact transitions and timing.
|
||||
/// </summary>
|
||||
public enum SpellCastPhase
|
||||
{
|
||||
Idle,
|
||||
Preparing, // server validating
|
||||
Casting, // syllables being played
|
||||
Releasing, // cast animation hit-frame → effect resolves
|
||||
Fizzled,
|
||||
Complete,
|
||||
}
|
||||
|
||||
public sealed class SpellCastStateMachine
|
||||
{
|
||||
public SpellCastPhase Phase { get; private set; } = SpellCastPhase.Idle;
|
||||
public uint SpellId { get; private set; }
|
||||
public uint? TargetGuid { get; private set; }
|
||||
public double StartedAt { get; private set; }
|
||||
public double CastDuration { get; private set; }
|
||||
|
||||
// Fired when the server confirms the cast started.
|
||||
public event Action<SpellCastStateMachine>? OnPhaseChanged;
|
||||
|
||||
public void BeginCast(uint spellId, uint? targetGuid, double castDurationSec, double nowSec)
|
||||
{
|
||||
SpellId = spellId;
|
||||
TargetGuid = targetGuid;
|
||||
StartedAt = nowSec;
|
||||
CastDuration = castDurationSec;
|
||||
TransitionTo(SpellCastPhase.Preparing);
|
||||
}
|
||||
|
||||
public void ServerAckCastingStart() => TransitionTo(SpellCastPhase.Casting);
|
||||
|
||||
public void ServerReleaseCast() => TransitionTo(SpellCastPhase.Releasing);
|
||||
|
||||
public void ServerCompleteCast() => TransitionTo(SpellCastPhase.Complete);
|
||||
|
||||
public void ServerFizzle() => TransitionTo(SpellCastPhase.Fizzled);
|
||||
|
||||
private void TransitionTo(SpellCastPhase next)
|
||||
{
|
||||
Phase = next;
|
||||
OnPhaseChanged?.Invoke(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Active buff/debuff on a character. Retail stacking rules:
|
||||
/// • Same category, same caster: replace if higher power
|
||||
/// • Same category, different caster: max power wins, both tracked
|
||||
/// • Different category: stack additively
|
||||
/// See r01 §5.
|
||||
/// </summary>
|
||||
public sealed class ActiveBuff
|
||||
{
|
||||
public uint SpellId { get; init; }
|
||||
public uint CasterGuid { get; init; }
|
||||
public SpellCategory Category { get; init; }
|
||||
public int Power { get; init; }
|
||||
public double StartedAt { get; init; }
|
||||
public double Duration { get; init; }
|
||||
public double EndsAt => StartedAt + Duration;
|
||||
public int StatModKey { get; init; }
|
||||
public int StatModValue { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fizzle + mana math. Retail-faithful formulas — see r01 §4.
|
||||
/// </summary>
|
||||
public static class SpellMath
|
||||
{
|
||||
/// <summary>
|
||||
/// Sigmoid fizzle curve. Returns chance-to-succeed in [0, 1].
|
||||
/// <c>chance = 1 / (1 + e^(-0.07 × (skill - difficulty)))</c>
|
||||
/// Hard floor: if skill < difficulty-50, return 0 (auto-fizzle).
|
||||
/// </summary>
|
||||
public static double ChanceOfSuccess(int skill, int difficulty)
|
||||
{
|
||||
if (skill < difficulty - 50) return 0.0;
|
||||
double x = -0.07 * (skill - difficulty);
|
||||
return 1.0 / (1.0 + Math.Exp(x));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes actual mana cost. Two sigmoid rolls at difficulty/2 then
|
||||
/// difficulty, each can reduce cost. See r01 §4.
|
||||
/// </summary>
|
||||
public static int ComputeManaCost(SpellDatEntry spell, int numTargets,
|
||||
int manaConvSkill, Random rng)
|
||||
{
|
||||
double cost = spell.BaseMana + spell.ManaMod * Math.Max(0, numTargets - 1);
|
||||
|
||||
// First reduction roll at half difficulty
|
||||
if (rng.NextDouble() < ChanceOfSuccess(manaConvSkill, spell.Power / 2))
|
||||
cost *= 0.5;
|
||||
// Second reduction roll at full difficulty
|
||||
if (rng.NextDouble() < ChanceOfSuccess(manaConvSkill, spell.Power))
|
||||
cost *= 0.5;
|
||||
|
||||
return (int)Math.Max(1, Math.Round(cost));
|
||||
}
|
||||
}
|
||||
170
src/AcDream.Core/Vfx/VfxModel.cs
Normal file
170
src/AcDream.Core/Vfx/VfxModel.cs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Vfx;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Scaffold for R4 — VFX / particle system data model.
|
||||
// Full research: docs/research/deepdives/r04-vfx-particles.md
|
||||
// Runtime GPU batching lives in AcDream.App/Rendering/Vfx (Silk.NET GL).
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 13 retail particle motion integrators. See r04 §1.
|
||||
/// Parabolic variants apply gravity with different orientation/decay rules.
|
||||
/// </summary>
|
||||
public enum ParticleType
|
||||
{
|
||||
Still = 0, // static, fades out in place
|
||||
LocalVelocity = 1, // moves at its spawn velocity
|
||||
Parabolic = 2, // gravity arc
|
||||
ParabolicLVGV = 3, // local+global velocity parabolic
|
||||
ParabolicLVGA = 4,
|
||||
ParabolicLVLA = 5,
|
||||
ParabolicGVGA = 6,
|
||||
ParabolicGVLA = 7,
|
||||
ParabolicLALV = 8,
|
||||
Swarm = 9, // orbits spawn point with randomness
|
||||
Explode = 10, // all particles push outward
|
||||
Implode = 11, // all particles pull inward
|
||||
GlobalVelocity = 12,
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum EmitterFlags : uint
|
||||
{
|
||||
None = 0,
|
||||
Additive = 0x01, // blend mode: SrcAlpha / One (vs default SrcAlpha / InvSrcAlpha)
|
||||
Billboard = 0x02,
|
||||
FaceCamera = 0x04,
|
||||
AttachLocal= 0x08, // particles follow parent anchor frame
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-emitter configuration from the <c>ParticleEmitterInfo</c> dat.
|
||||
/// See r04 §1 + DatReaderWriter.ParticleEmitterInfo.
|
||||
/// </summary>
|
||||
public sealed class EmitterDesc
|
||||
{
|
||||
public uint DatId { get; init; }
|
||||
public ParticleType Type { get; init; }
|
||||
public EmitterFlags Flags { get; init; }
|
||||
public uint TextureSurfaceId { get; init; } // 0x06xxxxxx
|
||||
public uint SoundOnSpawn { get; init; }
|
||||
|
||||
// Emission behavior
|
||||
public float EmitRate { get; init; } // particles / sec
|
||||
public int MaxParticles { get; init; }
|
||||
public float LifetimeMin { get; init; }
|
||||
public float LifetimeMax { get; init; }
|
||||
public float StartDelay { get; init; }
|
||||
public float TotalDuration { get; init; } // 0 = infinite
|
||||
|
||||
// Spawn geometry (disk annulus perpendicular to OffsetDir)
|
||||
public Vector3 OffsetDir { get; init; } = new(0, 0, 1);
|
||||
public float MinOffset { get; init; }
|
||||
public float MaxOffset { get; init; }
|
||||
public float SpawnDiskRadius { get; init; }
|
||||
|
||||
// Initial kinematics
|
||||
public Vector3 InitialVelocity { get; init; }
|
||||
public float VelocityJitter { get; init; }
|
||||
public Vector3 Gravity { get; init; } = new(0, 0, -9.8f);
|
||||
|
||||
// Appearance over lifetime (retail: start + end, linearly interpolated)
|
||||
public uint StartColorArgb { get; init; } = 0xFFFFFFFF;
|
||||
public uint EndColorArgb { get; init; } = 0xFFFFFFFF;
|
||||
public float StartAlpha { get; init; } = 1f;
|
||||
public float EndAlpha { get; init; } = 0f;
|
||||
public float StartSize { get; init; } = 0.5f;
|
||||
public float EndSize { get; init; } = 0.5f;
|
||||
public float StartRotation { get; init; }
|
||||
public float EndRotation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A PhysicsScript (0x3Axxxxxx range in retail) is a list of hooks to
|
||||
/// fire at specific start-times. Each hook creates an emitter or plays
|
||||
/// a sound. Chaining hooks at different times gives "animation".
|
||||
/// See r04 §6.
|
||||
/// </summary>
|
||||
public sealed class PhysicsScript
|
||||
{
|
||||
public uint ScriptId { get; init; }
|
||||
public IReadOnlyList<PhysicsScriptHook> Hooks { get; init; } = Array.Empty<PhysicsScriptHook>();
|
||||
}
|
||||
|
||||
public sealed record PhysicsScriptHook(
|
||||
float StartTime,
|
||||
PhysicsScriptHookType Type,
|
||||
uint RefDataId, // EmitterInfo / Sound / PartTransform
|
||||
int PartIndex, // attach to this part
|
||||
Vector3 Offset,
|
||||
bool IsParentLocal);
|
||||
|
||||
public enum PhysicsScriptHookType
|
||||
{
|
||||
CreateParticle = 18, // matches retail animation-hook type
|
||||
DestroyParticle= 19,
|
||||
PlaySound = 1,
|
||||
AnimationDone = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual runtime particle. Owned by the <c>ParticleSystem</c>;
|
||||
/// advanced per-frame.
|
||||
/// </summary>
|
||||
public struct Particle
|
||||
{
|
||||
public Vector3 Position;
|
||||
public Vector3 Velocity;
|
||||
public float SpawnedAt;
|
||||
public float Lifetime; // seconds
|
||||
public float Age;
|
||||
public uint ColorArgb; // current
|
||||
public float Size;
|
||||
public float Rotation;
|
||||
public bool Alive;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One active emitter instance. The <c>ParticleSystem</c> holds a pool
|
||||
/// of these; each one maintains its own particle array.
|
||||
/// </summary>
|
||||
public sealed class ParticleEmitter
|
||||
{
|
||||
public EmitterDesc Desc { get; init; } = null!;
|
||||
public Vector3 AnchorPos { get; set; }
|
||||
public Quaternion AnchorRot { get; set; } = Quaternion.Identity;
|
||||
public uint AttachedObjectId { get; set; } // 0 = world-space only
|
||||
public int AttachedPartIndex { get; set; } = -1;
|
||||
public Particle[] Particles { get; init; } = null!;
|
||||
public int ActiveCount;
|
||||
public float EmittedAccumulator; // fractional particles pending
|
||||
public float StartedAt; // game-time seconds
|
||||
public bool Finished;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Top-level particle orchestrator. App-layer renderer batches these.
|
||||
/// </summary>
|
||||
public interface IParticleSystem
|
||||
{
|
||||
/// <summary>Spawn an emitter attached to a world position (or entity).</summary>
|
||||
int SpawnEmitter(EmitterDesc desc, Vector3 anchor, Quaternion? rot = null,
|
||||
uint attachedObjectId = 0, int attachedPartIndex = -1);
|
||||
|
||||
/// <summary>Fire a full PhysicsScript at a target (the retail PlayScript dispatch).</summary>
|
||||
void PlayScript(uint scriptId, uint targetObjectId, float modifier = 1f);
|
||||
|
||||
/// <summary>Advance all active emitters by dt seconds.</summary>
|
||||
void Tick(float dt);
|
||||
|
||||
/// <summary>Stop an emitter early (e.g. cast interrupted).</summary>
|
||||
void StopEmitter(int handle, bool fadeOut);
|
||||
|
||||
/// <summary>Current active particle count (for HUD stats).</summary>
|
||||
int ActiveParticleCount { get; }
|
||||
int ActiveEmitterCount { get; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue