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 // this one call, server-sent ChannelBroadcast / damage
// notifications / spell learns / wield events all update // notifications / spell learns / wield events all update
// the corresponding client-side state without further glue. // 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( AcDream.Core.Net.GameEventWiring.WireAll(
_liveSession.GameEvents, Items, Combat, SpellBook, Chat, LocalPlayer, _liveSession.GameEvents, Items, Combat, SpellBook, Chat, LocalPlayer,
TurbineChat, 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) => onSkillsUpdated: (runSkill, jumpSkill) =>
{ {
// K-fix7 (2026-04-26): cache the latest server-sent // 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 // or Jump (22) entries — we only care about those two for
// movement physics. Caller (GameWindow) plumbs this into the // movement physics. Caller (GameWindow) plumbs this into the
// active PlayerMovementController + caches it for the next // active PlayerMovementController + caches it for the next
// EnterPlayerModeNow construction. Each value is `init + ranks` // EnterPlayerModeNow construction.
// (the holtburger-named "init" field is already the //
// attribute-derived initial component, ranks is XP-bought // K-fix13 (2026-04-26): the wire's `init` field is ONLY
// additions; matches retail's GetCreatureSkill.Current minus // InitLevel (training tier from chargen, per ACE
// augs / multipliers / vitae). // GameEventPlayerDescription.cs:317 "init_level, for
Action<int /*runSkill*/, int /*jumpSkill*/>? onSkillsUpdated = null) // 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(dispatcher);
ArgumentNullException.ThrowIfNull(items); 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}")}"); 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; 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) if (localPlayer is not null)
{ {
foreach (var attr in p.Value.Attributes) foreach (var attr in p.Value.Attributes)
@ -321,16 +351,30 @@ public static class GameEventWiring
int jumpSkill = -1; int jumpSkill = -1;
foreach (var s in p.Value.Skills) 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; if (s.SkillId == 24u) runSkill = total;
else if (s.SkillId == 22u) jumpSkill = 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 (runSkill >= 0 || jumpSkill >= 0)
{
if (dumpPd)
Console.WriteLine($"vitals: PD-skills run={runSkill} jump={jumpSkill}");
onSkillsUpdated(runSkill, jumpSkill); onSkillsUpdated(runSkill, jumpSkill);
}
} }
// Issue #7 — enchantment block: feed each entry into the // Issue #7 — enchantment block: feed each entry into the