diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 92a7913..7143d4b 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1220,9 +1220,37 @@ public sealed class GameWindow : IDisposable // this one call, server-sent ChannelBroadcast / damage // notifications / spell learns / wield events all update // the corresponding client-side state without further glue. + // K-fix13 (2026-04-26): cache portal.dat's SkillTable so the + // skill-formula resolver can apply the AttributeFormula + // contribution. Without this, our wire-derived "skill" + // value was missing the dominant attribute-derived term + // and jumps undershot retail by ~30 % at typical + // attribute levels. + var skillTable = _dats?.Get(0x0E000004u); + AcDream.Core.Net.GameEventWiring.WireAll( _liveSession.GameEvents, Items, Combat, SpellBook, Chat, LocalPlayer, TurbineChat, + resolveSkillFormulaBonus: (skillId, attrCurrents) => + { + // ACE GetFormula (AttributeFormula.cs:55-): when + // formula.X (Attribute1Multiplier) is 0, the formula + // is "no attribute contribution" and the function + // returns 0. Otherwise: + // bonus = (attr1 * Mult1 + attr2 * Mult2) / Divisor + Additive + if (skillTable?.Skills is null) return 0u; + if (!skillTable.Skills.TryGetValue( + (DatReaderWriter.Enums.SkillId)skillId, out var skillBase)) + return 0u; + var f = skillBase.Formula; + if (f.Attribute1Multiplier == 0 || f.Divisor == 0) return 0u; + attrCurrents.TryGetValue((uint)f.Attribute1, out uint a1); + attrCurrents.TryGetValue((uint)f.Attribute2, out uint a2); + long num = (long)a1 * f.Attribute1Multiplier + + (long)a2 * f.Attribute2Multiplier; + long bonus = num / f.Divisor + f.AdditiveBonus; + return bonus < 0 ? 0u : (uint)bonus; + }, onSkillsUpdated: (runSkill, jumpSkill) => { // K-fix7 (2026-04-26): cache the latest server-sent diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index a33b216..a4515cc 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -43,12 +43,25 @@ public static class GameEventWiring // or Jump (22) entries — we only care about those two for // movement physics. Caller (GameWindow) plumbs this into the // active PlayerMovementController + caches it for the next - // EnterPlayerModeNow construction. Each value is `init + ranks` - // (the holtburger-named "init" field is already the - // attribute-derived initial component, ranks is XP-bought - // additions; matches retail's GetCreatureSkill.Current minus - // augs / multipliers / vitae). - Action? onSkillsUpdated = null) + // EnterPlayerModeNow construction. + // + // K-fix13 (2026-04-26): the wire's `init` field is ONLY + // InitLevel (training tier from chargen, per ACE + // GameEventPlayerDescription.cs:317 "init_level, for + // training/specialized bonus"). The AttributeFormula + // contribution (Strength/Quickness/etc-derived) is computed + // by ACE at runtime via portal.dat's SkillTable + attribute + // currents. Without it our totals undershoot the real + // Current skill by 50-100 points for movement skills, which + // is why jumps looked too short. The optional + // resolveSkillFormulaBonus callback lets the caller + // (GameWindow) plug in the AttributeFormula contribution + // using its cached SkillTable + attribute currents — when + // present, total skill = formulaBonus + init + ranks + // (matching ACE's CreatureSkill.Current minus + // augs/multipliers/vitae which we still don't model). + Action? onSkillsUpdated = null, + Func /*attrCurrents*/, uint /*formulaBonus*/>? resolveSkillFormulaBonus = null) { ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(items); @@ -271,6 +284,23 @@ public static class GameEventWiring Console.WriteLine($"vitals: PlayerDescription body.len={e.Payload.Length} parsed={(p is null ? "NULL" : $"vec={p.Value.VectorFlags} attrs={p.Value.Attributes.Count} spells={p.Value.Spells.Count}")}"); if (p is null) return; + // K-fix13 (2026-04-26): build attrId → current map while + // iterating attributes so the skill-formula resolver below + // can apply (attr1.current * mult1 + attr2.current * mult2) + // / divisor + additive. "current" here = ranks + start + // (the formula-relevant attribute level pre-augs / pre-buffs). + // Built unconditionally (not inside the localPlayer guard) + // because the skill-formula resolver needs it even if no + // LocalPlayerState is wired. + var attrCurrents = new Dictionary(); + foreach (var attr in p.Value.Attributes) + { + // PD-attr ids 1-6 are primary attributes (Str / End + // / Coord / Quick / Focus / Self). 7/8/9 are vitals. + if (attr.AtType >= 1 && attr.AtType <= 6) + attrCurrents[attr.AtType] = attr.Ranks + attr.Start; + } + if (localPlayer is not null) { foreach (var attr in p.Value.Attributes) @@ -321,16 +351,30 @@ public static class GameEventWiring int jumpSkill = -1; foreach (var s in p.Value.Skills) { - int total = (int)(s.Init + s.Ranks); + if (s.SkillId != 22u && s.SkillId != 24u) continue; + + // K-fix13: total = AttributeFormula(skill, attrs) + // + InitLevel (s.Init from wire) + // + Ranks (s.Ranks from wire) + // matches ACE CreatureSkill.Current minus + // augs/multipliers/vitae. The attribute-formula + // contribution is the dominant term for movement + // skills (typically 50-100 points) and was being + // dropped pre-fix13 — that's the root cause of + // jumps being too short relative to retail. + uint formulaBonus = resolveSkillFormulaBonus is not null + ? resolveSkillFormulaBonus(s.SkillId, attrCurrents) + : 0u; + int total = (int)(formulaBonus + s.Init + s.Ranks); if (s.SkillId == 24u) runSkill = total; else if (s.SkillId == 22u) jumpSkill = total; + + if (dumpPd) + Console.WriteLine( + $"vitals: PD-skill id={s.SkillId} init={s.Init} ranks={s.Ranks} formulaBonus={formulaBonus} total={total}"); } if (runSkill >= 0 || jumpSkill >= 0) - { - if (dumpPd) - Console.WriteLine($"vitals: PD-skills run={runSkill} jump={jumpSkill}"); onSkillsUpdated(runSkill, jumpSkill); - } } // Issue #7 — enchantment block: feed each entry into the