Skip to content
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
27 changes: 23 additions & 4 deletions apps/save-editor/lib/features/editor/domain/npc_attributes.dart
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 @@ -37,6 +37,23 @@ class NpcAttributeRow {

/// Attribute name (e.g. `Health`). Doubles as the row label.
final String key;

/// The owning `AttributeSet_*` class, recovered from the typed path. The core
/// does not send it as its own field, but every row's path carries it — and
/// without it the shared policy cannot tell a Fatigue
/// `RecoveryRatePerHourOfSleep` (inert) from the Health and Mana ones (real).
String? get setClass {
for (final path in [basePath, currentPath]) {
final index = path.indexOf('AttributeSetsByClass');
if (index < 0 || index + 1 >= path.length) continue;
var value = path[index + 1].trim();
if (value.startsWith('{') && value.endsWith('}')) {
value = value.substring(1, value.length - 1);
}
if (value.isNotEmpty) return value;
}
return null;
}
final double base;
final double current;

Expand All @@ -63,9 +80,11 @@ class NpcAttributesResult {
attributes: raw
.whereType<Map>()
.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))
// Hide what the game derives or never reads, same as for the player;
// it all stays editable in the All-data browser. The set class has to
// come along: `RecoveryRatePerHourOfSleep` is hidden on Fatigue and
// kept on Health and Mana.
.where((row) => !heroAttributeHidden(row.key, row.setClass))
.toList(growable: false),
);
}
Expand Down
Loading
Loading