fix(player): apply AttributeFormula to wire-derived Run/Jump skill — root cause of short jumps

Found the underlying cause of the user's persistent
"jumps don't reach retail height" complaint. The wire's SkillEntry
`init` field is ONLY the InitLevel (training/specialized
chargen bonus, per ACE GameEventPlayerDescription.cs:317
"init_level, for training/specialized bonus from character
creation"). It does NOT include the AttributeFormula
contribution.

ACE's CreatureSkill.Current is computed as:
  AttributeFormula(skill, attrs) + InitLevel + Ranks
  + augs + multipliers - vitae

Pre-fix13 we used `init + ranks` only — dropping the
AttributeFormula term, which is the DOMINANT component for
movement skills (50-100 points typical). For our character
that meant Jump skill 208 instead of the actual ~280-310,
giving a 3.11 m peak instead of the retail ~4 m peak. Hence
"feels like the upward acceleration is too slow and we don't
reach the same height".

Fix:
- GameWindow caches portal.dat's SkillTable (0x0E000004u) at
  WireAll time. Each entry has a SkillFormula with attr1/
  attr2/multipliers/divisor/additive constants
  (formula:  bonus = (attr1*M1 + attr2*M2)/Div + Additive).
- GameEventWiring.WireAll gains a
  `resolveSkillFormulaBonus(skillId, attrCurrents)` callback.
  GameWindow plugs in a resolver that looks up
  SkillTable.Skills[skillId].Formula, applies the formula
  using the player's current attribute values from PD.
- The PD handler builds attrId→current map (ranks+start) from
  the parsed attributes before iterating skills, then passes
  it to the resolver for Run (24) and Jump (22).
- Total skill = formulaBonus + InitLevel + Ranks. Matches ACE
  Current minus augs/multipliers/vitae (close enough — those
  add maybe ±10 % at most).

ACDREAM_DUMP_VITALS=1 logs add a per-skill line:
  "vitals: PD-skill id=22 init=N ranks=N formulaBonus=N total=N"
so live testing can confirm the formula is applied.

Tests stay 1222 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-26 20:56:38 +02:00
parent 4b6fcffa01
commit a060f4fc98
2 changed files with 83 additions and 11 deletions

View file

@ -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<DatReaderWriter.DBObjs.SkillTable>(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

View file

@ -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<int /*runSkill*/, int /*jumpSkill*/>? 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
// <c>resolveSkillFormulaBonus</c> 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<int /*runSkill*/, int /*jumpSkill*/>? onSkillsUpdated = null,
Func<uint /*skillId*/, IReadOnlyDictionary<uint, uint> /*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<uint, uint>();
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