Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/save-editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Changed

- The advanced attributes are sorted into Combat & movement, Diving, Sleep &
rest and Intoxication instead of one long list, every value has a proper name
in your language, and hovering a name explains what it does in the game.
- Values the game never acts on are gone from the attribute list: Toughness,
which no longer limits what you can carry, and hunger, thirst and fatigue,
which belong to a survival mode that cannot be switched on. They stay
editable under All data.
- Saving is much faster: a save with eight changed values took eleven seconds
and now takes one.

### Fixed

- The attribute list offered a second "Magic Circle", identically named to the
one under Skills but without any effect in the game. It is gone; the circle is
set under Skills.
- Version 1.2.1 said an NPC's position cannot be changed because the game
restores it from the level. That was wrong.
- Removing an item from a list and then editing another item in the same list
Expand Down
202 changes: 174 additions & 28 deletions apps/save-editor/lib/features/editor/domain/hero_attributes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,43 @@ class HeroAttribute {
final double? currentValue;
}

enum HeroAttributeGroup { core, combat, resistances, thieving, advanced }
enum HeroAttributeGroup {
core,
combat,
resistances,
thieving,
diving,
sleep,
intoxication,
advanced,
}

/// The key an attribute is addressed by throughout the curated view: its plain
/// id, or `<AttributeSet>_<id>` when that id exists in more than one set.
///
/// The separator is an underscore, not a dot, because these keys are also the
/// arm names of the ICU `select` messages that carry the labels and tooltips,
/// and ICU rejects a dot there.
///
/// Four ids are shared between sets — `FillRatio`, `FillRatioPeriod` and
/// `MaxThresholdIndex` across Hunger/Thirst/Fatigue, and
/// `RecoveryRatePerHourOfSleep` across Health/Mana/Fatigue. They mean something
/// different in each, so grouping and labelling both need the set. Everything
/// else stays keyed by the bare id, which keeps the label table and the group
/// lists readable.
String heroAttributeKey(String id, [String? setClass]) {
if (!_setQualifiedIds.contains(id)) return id;
final set = setClass?.split('.').last ?? '';
if (!set.startsWith('AttributeSet_')) return id;
return '${set.substring('AttributeSet_'.length)}_$id';
}

const _setQualifiedIds = <String>{
'FillRatio',
'FillRatioPeriod',
'MaxThresholdIndex',
'RecoveryRatePerHourOfSleep',
};

const heroCoreAttributeOrder = [
'Health',
Expand All @@ -54,24 +90,113 @@ const heroCoreAttributeOrder = [
'Level',
'Experience',
'SkillPoints',
'MagicianLevel',
];

// The per-weapon critical-hit values used to have their own "Kampffertigkeiten"
// group; they are now hidden from the curated attribute view entirely (see
// [heroHiddenAttributeIds]) — still editable via the All-data browser. This
// list stays empty so the combat group machinery keeps compiling but never
// surfaces.
const heroCombatAttributes = <String>[];
/// Combat and movement. The per-weapon critical-hit values that used to live
/// here are hidden now (see [heroHiddenAttributeIds]); what remains is the
/// poise system plus the two global factors.
///
/// `SuperArmor` is the stagger pool: a hit subtracts its super-armour damage
/// and only staggers the hero once the pool is empty, so a higher value means
/// fewer interruptions. Its maximum is `20 + 3 x Level` plus whatever the worn
/// armour adds, which is why base and current differ in a real save.
const heroCombatAttributes = [
'SuperArmor',
'MaxSuperArmor',
'DamageMultiplier',
'SpeedModifier',
];

/// Breath and diving. `Oxygen` is literally seconds of air: the swim ability
/// subtracts `OxygenDepletionRate` (always 1) every second under water and
/// kills the hero at zero, and the Diving skill raises the capacity from 45 to
/// 150 while tripling the surface recovery.
const heroDivingAttributes = [
'Oxygen',
'MaxOxygen',
'OxygenDepletionRate',
'OxygenRecoveryRate',
'CriticalLevelPercent',
];

/// Sleeping in a bed. `SleepTime` is the budget of restful hours behind the
/// game's "Sleep for:" slider — hours beyond it are the ones the game marks
/// "No resting bonus" — and it refills by `SleepTimeRecoveryAmount` every
/// `SleepTimeRecoveryPeriod`. The three per-hour recovery rates say what an
/// hour of sleep restores, which is why they belong here rather than with the
/// pools they act on.
const heroSleepAttributes = [
'SleepTime',
'MaxSleepTime',
'SleepTimeRecoveryAmount',
'SleepTimeRecoveryPeriod',
'MaxRestTime',
'Health_RecoveryRatePerHourOfSleep',
'Mana_RecoveryRatePerHourOfSleep',
];

/// Booze and swampweed. Both run the same machine: a consumable adds points,
/// the level falls into one of three tiers that trade attributes against each
/// other, and the value decays by its depletion rate until sober.
const heroIntoxicationAttributes = [
'Alcohol',
'MaxAlcohol',
'AlcoholDepletionRate',
'Swampweed',
'MaxSwampweed',
'SwampweedDepletionRate',
];

/// Attribute ids hidden from the curated hero/NPC attribute view (the game
/// derives these from the learned skills, so editing them by hand is
/// misleading). They remain reachable in the All-data property browser.
///
/// Each one is the attribute a `GE_Skill_*` class raises, and the game
/// re-derives it from that class when the savegame is loaded: a save edited so
/// that only the Magic Circle CLASS said circle 6 — while MagicianLevel still
/// said -1 — let the hero use a circle 4 rune in game, and rune usability is
/// stated against MagicianLevel. So the value written here never survives the
/// load, and the skill's own control (Talente) is the only one that works.
const heroHiddenAttributeIds = <String>{
'Critical_Fists',
'Critical_OneHand',
'Critical_TwoHand',
'Critical_Orc',
// Magic Circle. Its label collided with the Talente row's, so the Attribute
// tab showed two identical "Magischer Kreis" controls, only one of which did
// anything.
'MagicianLevel',
Comment thread
dh0er marked this conversation as resolved.
..._heroUnusedAttributeIds,
};

/// Attributes the shipped game carries but never acts on — encumbrance, which
/// was designed and then left out: nothing in the script layer reads
/// `Toughness`, and carrying capacity is unlimited in play. The game still
/// SHOWS Toughness on its own character screen (`ui_attribute_toughness`), but
/// the number drives nothing, and A/B/C are the coefficients of the curve that
/// was meant to compute it — they do not reproduce the values the game actually
/// stores under any simple polynomial.
const _heroUnusedAttributeIds = <String>{
'Toughness',
'ToughnessA',
'ToughnessB',
'ToughnessC',
// Hunger, thirst and fatigue: the game's optional Survival mode, which never
// became reachable. Measured in game on 2026-08-13 with a UE4SS probe:
// GetSurvivalModeState() was forced to true BEFORE the hero loaded, the six
// need abilities are granted, the attribute sets are present, and Hunger sat
// at 900/1000 — the harshest stage, which owes -15% Strength and 1 HP per
// second. Strength stayed 30.0 and health stayed 71.0 for a minute. The
// abilities never activate, so every one of these values is inert.
'Hunger', 'MaxHunger',
'Thirst', 'MaxThirst',
'Fatigue', 'MaxFatigue',
// These three exist ONLY in the Hunger/Thirst/Fatigue sets, so hiding them by
// bare id is exact.
'FillRatio', 'FillRatioPeriod', 'MaxThresholdIndex',
// This one also exists on Health and Mana, where it is real — so it has to be
// hidden by its set-qualified key, not by id.
'Fatigue_RecoveryRatePerHourOfSleep',
};

const heroResistanceAttributes = [
Expand All @@ -91,16 +216,36 @@ const heroThievingAttributes = [
'PickPocketing',
];

HeroAttributeGroup heroAttributeGroup(String id) {
if (heroCoreAttributeOrder.contains(id)) return HeroAttributeGroup.core;
if (heroCombatAttributes.contains(id)) return HeroAttributeGroup.combat;
if (heroResistanceAttributes.contains(id)) {
return HeroAttributeGroup.resistances;
/// The group an attribute belongs to. [setClass] disambiguates the handful of
/// ids that exist in several attribute sets; without it those fall back to
/// their bare id, which no group claims, so they land in `advanced`.
/// Whether an attribute is hidden from the curated view. [setClass] matters for
/// the ids that exist in several sets: `RecoveryRatePerHourOfSleep` is inert on
/// Fatigue but real on Health and Mana.
bool heroAttributeHidden(String id, [String? setClass]) =>
heroHiddenAttributeIds.contains(id) ||
heroHiddenAttributeIds.contains(heroAttributeKey(id, setClass));

HeroAttributeGroup heroAttributeGroup(String id, [String? setClass]) {
final key = heroAttributeKey(id, setClass);
for (final entry in _groupOrders.entries) {
if (entry.value.contains(key)) return entry.key;
}
if (heroThievingAttributes.contains(id)) return HeroAttributeGroup.thieving;
return HeroAttributeGroup.advanced;
}

/// Every group's ordered member list, in sidebar order. `advanced` is absent on
/// purpose: it is the catch-all for anything unlisted.
const _groupOrders = <HeroAttributeGroup, List<String>>{
HeroAttributeGroup.core: heroCoreAttributeOrder,
HeroAttributeGroup.combat: heroCombatAttributes,
HeroAttributeGroup.resistances: heroResistanceAttributes,
HeroAttributeGroup.thieving: heroThievingAttributes,
HeroAttributeGroup.diving: heroDivingAttributes,
HeroAttributeGroup.sleep: heroSleepAttributes,
HeroAttributeGroup.intoxication: heroIntoxicationAttributes,
};

/// Display label for an attribute id. SkillPoints are Gothic's learn points,
/// which is what players actually look for.
String heroAttributeLabel(String id) {
Expand All @@ -112,19 +257,15 @@ String heroAttributeLabel(String id) {
/// groups. Shared by the player's [parseHeroAttributes] sort and the NPC
/// attribute panel so NPC rows order identically to the player's within a
/// group (and unlisted/advanced ids fall to the end). Exposes [_groupRank].
int heroAttributeRank(String id) => _groupRank(id);

int _groupRank(String id) {
final group = heroAttributeGroup(id);
final order = switch (group) {
HeroAttributeGroup.core => heroCoreAttributeOrder,
HeroAttributeGroup.combat => heroCombatAttributes,
HeroAttributeGroup.resistances => heroResistanceAttributes,
HeroAttributeGroup.thieving => heroThievingAttributes,
HeroAttributeGroup.advanced => null,
};
int heroAttributeRank(String id, [String? setClass]) =>
_groupRank(id, setClass);

int _groupRank(String id, [String? setClass]) {
final key = heroAttributeKey(id, setClass);
final group = heroAttributeGroup(id, setClass);
final order = _groupOrders[group];
if (order == null) return 1 << 20;
return (group.index << 12) + order.indexOf(id);
return (group.index << 12) + order.indexOf(key);
}

/// Fold typed search hits into hero attributes. Only editable FloatProperty
Expand All @@ -146,7 +287,6 @@ List<HeroAttribute> parseHeroAttributes(List<TypedPropertyHit> hits) {
final idSegment = path[path.length - 2];
if (!idSegment.startsWith('{') || !idSegment.endsWith('}')) continue;
final id = idSegment.substring(1, idSegment.length - 1);
if (heroHiddenAttributeIds.contains(id)) continue;
final setIndex = path.indexOf('AttributeSetsByClass');
var setClass = '';
if (setIndex >= 0 && setIndex + 1 < path.length) {
Expand All @@ -155,6 +295,9 @@ List<HeroAttribute> parseHeroAttributes(List<TypedPropertyHit> hits) {
setClass = seg.substring(1, seg.length - 1);
}
}
// Needs the set: `RecoveryRatePerHourOfSleep` is inert on Fatigue but real
// on Health and Mana, so the bare id cannot decide this.
if (heroAttributeHidden(id, setClass)) continue;
final prefix = path.sublist(0, path.length - 1).join(' ');
final builder = byPrefix.putIfAbsent(
prefix,
Expand All @@ -171,7 +314,10 @@ List<HeroAttribute> parseHeroAttributes(List<TypedPropertyHit> hits) {
}
final attributes = byPrefix.values.map((b) => b.build()).toList()
..sort((a, b) {
final rank = _groupRank(a.id).compareTo(_groupRank(b.id));
final rank = _groupRank(
a.id,
a.setClass,
).compareTo(_groupRank(b.id, b.setClass));
if (rank != 0) return rank;
final byId = a.id.compareTo(b.id);
if (byId != 0) return byId;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'hero_attributes.dart' show heroHiddenAttributeIds;
import 'hero_attributes.dart' show heroAttributeHidden;

/// One pending `private.typed.setValue` edit produced by the NPC attribute
/// editor. Mirrors [TypedValueEdit] in hero_attributes.dart but kept local so
Expand Down Expand Up @@ -65,7 +65,7 @@ class NpcAttributesResult {
.map((m) => NpcAttributeRow.fromJson(m.cast<String, Object?>()))
// Hide the per-weapon critical values from the curated view (same as
// the player); they stay editable in the All-data browser.
.where((row) => !heroHiddenAttributeIds.contains(row.key))
.where((row) => !heroAttributeHidden(row.key))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NPC hide ignores set-qualified ids

Medium Severity

heroAttributeHidden and heroAttributeGroup only treat RecoveryRatePerHourOfSleep as Fatigue-inert or Health/Mana sleep stats when given setClass. The NPC filter calls heroAttributeHidden(row.key) with no set, and grouping/ranking do the same, even though _setClassFromPaths already recovers it for labels. Fatigue recovery therefore stays in the curated NPC list, and the Health/Mana rates stay in Advanced instead of Sleep. The legacy player fallback has the same hide gap.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b9ee3af. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass the NPC attribute set into the hidden-value policy

When an NPC response contains RecoveryRatePerHourOfSleep from AttributeSet_Fatigue, this call omits the set class, so heroAttributeHidden cannot match the new set-qualified Fatigue_RecoveryRatePerHourOfSleep entry and the inert survival value remains visible. The same missing context in npc_attributes_panel.dart:186-193 also puts the live Health/Mana versions in Advanced rather than Sleep; extract the set class from basePath/currentPath and pass it consistently to the hidden, grouping, and ranking helpers.

Useful? React with 👍 / 👎.

.toList(growable: false),
);
}
Expand Down
Loading
Loading