Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -8,6 +8,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- Browse and edit chest and door locks under World → Locks, with search and
filters. Relocking a door also closes it.

### Changed

- Story states are easier to browse, with clearer filters and help. Unused
entries are hidden by default and can be shown when needed.

## [1.4.1] - 2026-09-06

### Added
Expand Down
9 changes: 6 additions & 3 deletions apps/save-editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,19 @@ game install. For modding, use the [`gore` CLI](../../docs/guide/README.md) or
## Compatibility

Tested with Steam game version CL168781. Other game versions are not guaranteed:
the bundled item, item-stat and named-location catalogs come from specific game
cooks and can become stale when a patch moves, renames or changes game data.
the bundled item, item-stat, lock and named-location catalogs come from specific
game cooks and can become stale when a patch moves, renames or changes game data.
Keep backups and use a Save Editor build qualified for the updated game
version.

The bundled item stats (`assets/item_stats.json`) carry what the shipped script
cache says about every item — its type tag, damage, requirements, value and
description key — plus the game's own inventory filter tables.
`assets/glossary_images.json` maps each glossary entry to its portrait file.
Regenerate both after a game update with the scripts in
`assets/lock_catalog.json` lists every lockable chest and door in the game with
its difficulty, keys and region — a save records only the locks the player has
already opened, so the Welt tab's lock list needs the other half from here.
Regenerate all three after a game update with the scripts in
`apps/save-editor/tools/`; each file header documents the commands.

The glossary portraits themselves are not bundled: they are loose PNGs in the
Expand Down
1 change: 1 addition & 0 deletions apps/save-editor/assets/lock_catalog.json

Large diffs are not rendered by default.

73 changes: 70 additions & 3 deletions apps/save-editor/lib/features/editor/domain/editor_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:goresave/features/editor/domain/editor_settings_store.dart';
import 'package:goresave/features/editor/domain/game_time.dart';
import 'package:goresave/features/editor/domain/glossary_models.dart';
import 'package:goresave/features/editor/domain/hero_attributes.dart';
import 'package:goresave/features/editor/domain/locks_models.dart';
import 'package:goresave/features/editor/domain/npc_actors_page.dart';
import 'package:goresave/features/editor/domain/npc_attributes.dart';
import 'package:goresave/features/editor/domain/npc_position.dart';
Expand Down Expand Up @@ -1197,9 +1198,7 @@ class EditorNotifier extends StateNotifier<EditorState> {
/// survive.
void refreshSelectedActorStatus({required String id, required bool isDead}) {
final selected = state.selectedActor;
if (selected.isPlayer ||
selected.id != id ||
selected.isDead == isDead) {
if (selected.isPlayer || selected.id != id || selected.isDead == isDead) {
return;
}
state = state.copyWith(
Expand Down Expand Up @@ -1782,6 +1781,25 @@ class EditorNotifier extends StateNotifier<EditorState> {
state = state.copyWith(error: _l10n.editorTraderArrayConflict);
return false;
}
// Lock changes splice the lock set and door arrays; relocking also pairs
// message names with structs by index. Splitting a conflicting raw edit
// into another write can shift its target or remap a door message, so
// reject every affected container before any sub-write reaches the save.
final lockEdits = allEdits
.where((keyed) => keyed.edit['path'] == 'private.locks.setUnlocked')
.toList();
if (lockEdits.isNotEmpty) {
for (final keyed in allEdits) {
final path = _rawTypedEditPath(keyed.edit);
if (path != null &&
lockEdits.any((lock) => structuredEditRewrites(lock.edit, path))) {
state = state.copyWith(
error: _l10n.editorConflictingPropertyEdits(path.join(' › ')),
);
return false;
}
}
}
final fixedBatch = allEdits
.where(
(k) =>
Expand Down Expand Up @@ -4289,6 +4307,40 @@ class EditorNotifier extends StateNotifier<EditorState> {
}
}

/// Which locks this save records as already opened.
///
/// The catalog of locks that EXIST is bundled with the app, not read from the
/// save — a fresh game carries an empty set — so the panel loads the two and
/// joins them.
Future<LocksResult> loadLocks() async {
final path = state.selectedPath;
if (path == null) {
return LocksResult(error: _l10n.editorNoSaveSelected);
}
try {
final response = await _execute(
'private.locks.list',
payload: {'path': path},
);
if (response['ok'] != true) {
return LocksResult(
error: _l10n.editorLockListFailed(_errorDetails(response)),
);
}
return LocksResult.fromJson(
(response['data'] as Map).cast<String, Object?>(),
);
} catch (error) {
return LocksResult(error: _l10n.editorLockListFailed('$error'));
}
}

/// Pending-edit key of the queued lock changes. One entry holds every toggle:
/// `private.locks.setUnlocked` is value-addressed (the core finds the lock by
/// name on a fresh parse per edit), so a whole panel of them batches into a
/// single `write_save` and needs no place in [splicingPaths].
static const pendingLocksKey = 'world.locks';

/// Pending-edit key prefix for a queued faction forgive (`<prefix><guild>`).
static const _factionForgivePrefix = 'factions.forgive:';

Expand Down Expand Up @@ -4484,6 +4536,10 @@ String? _structuredEditTarget(Map<String, Object?> edit) {
foldEditTargetPart(fields['character']),
foldEditTargetPart(fields['entry']),
]);
// Two intents for the same lock in one write contradict each other; the
// core refuses the pair, so catch it here rather than at save time.
case 'private.locks.setUnlocked':
return key([foldEditTargetPart(fields['lock'])]);
case 'private.glossary.setSegment':
return key([
_foldAssetName(fields['documentClass']),
Expand Down Expand Up @@ -4833,6 +4889,15 @@ bool structuredEditRewrites(
'CharacterKnowledgeByUniqueName',
character,
);
case 'private.locks.setUnlocked':
return _pathHasName(typedPath, 'm_UnlockedLocks') ||
(fields['unlocked'] == false &&
const [
'm_DoorsOpen',
'm_DoorsClosed',
'm_SavedDoorsMessagesName',
'm_SavedDoorsMessagesStruct',
].any((name) => _pathHasName(typedPath, name)));
// Claims a whole slot — but only in the inventory it targets; another
// actor's slots are a different subtree.
case 'private.inventory.addItem':
Expand Down Expand Up @@ -4946,6 +5011,8 @@ bool _mayInvalidateOrdinals(Map<String, Object?> edit) {
'private.inventory.repairSlots',
'private.knowledge.addCharacter',
'private.knowledge.setEntry',
// Set-adds or set-removes a name in m_UnlockedLocks.
'private.locks.setUnlocked',
'private.npc.revive',
'private.npc.setRelationship',
'private.glossary.setSegment',
Expand Down
108 changes: 108 additions & 0 deletions apps/save-editor/lib/features/editor/domain/lock_catalog.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import 'dart:convert';

import 'package:flutter/services.dart' show rootBundle;

/// Whether a lock sits on a chest or on a door. The save does not distinguish
/// the two — both are names in the same set — so the split comes from the
/// catalog, where it is read off the game's own class defaults.
enum LockKind { chest, door }

/// How many of the game's four difficulty pips are filled for a raw 1..7 tier.
///
/// The lockpicking widget (`/Game/UI/LockPick/W_LockPickUI`) holds exactly four
/// `Difficulty_1..4` images and fills each one when the tier reaches its own
/// threshold. The thresholds are 1, 2, 4, and 6 — so the seven internal tiers
/// collapse onto four bars as 1 / 2,3 / 4,5 / 6,7, and the player never sees
/// the raw number anywhere in the game.
const lockDifficultyBarThresholds = [1, 2, 4, 6];

/// Total pips the game draws; the unfilled ones use its "empty" texture.
const lockDifficultyBarCount = 4;

int lockDifficultyBars(int difficulty) =>
lockDifficultyBarThresholds.where((t) => difficulty >= t).length;

/// One lockable chest or door in the game.
///
/// [name] is the key the save is addressed by: the lock-bearing class's
/// `m_UniqueName`, which is exactly what turns up in
/// `LockPersistentData.m_UnlockedLocks` once the player has opened it. The
/// class's own `m_Lock` id ([lockId]) is a different string and never appears
/// in a save; it is carried only so a row can be traced back to the script.
class LockEntry {
LockEntry({
required this.name,
required this.kind,
required this.area,
required this.difficulty,
required this.lockId,
required this.keys,
required this.randomized,
}) : search = [name, ...keys].join(' ').toLowerCase();

final String name;
final LockKind kind;

/// Area code, matching a `LocationArea.id`, or `''` when unknown.
final String area;

/// Lockpicking difficulty, 1–7. Null for a lock that only ever opens with a
/// key, and for the chests whose lock the randomizer assigns at runtime.
final int? difficulty;

/// The script-side lock id (`OC_Chest_Dexter_Lock`). Never a save key.
final String? lockId;

/// Item ids of the keys that open this lock, if any.
final List<String> keys;

/// Whether the randomized-lock subsystem registers this chest. Those carry no
/// fixed difficulty of their own, but they do get a lock in a running game.
final bool randomized;

/// Lowercased [name] plus key ids, computed ONCE at parse time. The list is
/// filtered client-side, so each keystroke must be a substring scan over
/// cached strings rather than hundreds of fresh `toLowerCase()` calls.
final String search;
}

/// The bundled catalog of every lock in the game, generated from the shipped
/// AngelScript cache by `apps/save-editor/tools/build_lock_catalog.py`.
/// Regenerate after a game patch: the lock set is cook-specific.
class LockCatalog {
LockCatalog({required this.locks})
: _byName = {for (final lock in locks) lock.name.toLowerCase(): lock};

final List<LockEntry> locks;
final Map<String, LockEntry> _byName;

/// Case-insensitive, matching UE `FName` semantics: a save may spell a lock
/// differently from the catalog and still mean the same lock.
LockEntry? byName(String name) => _byName[name.toLowerCase()];

static LockCatalog fromJsonString(String json) {
final root = jsonDecode(json) as Map<String, Object?>;
final locks = (root['locks'] as List? ?? const [])
.whereType<Map<String, Object?>>()
.map(
(lock) => LockEntry(
name: lock['n'] as String? ?? '',
kind: lock['k'] == 'door' ? LockKind.door : LockKind.chest,
area: lock['a'] as String? ?? '',
difficulty: (lock['d'] as num?)?.toInt(),
lockId: lock['l'] as String?,
keys: (lock['keys'] as List? ?? const [])
.whereType<String>()
.toList(growable: false),
randomized: lock['r'] == true,
),
)
.where((lock) => lock.name.isNotEmpty)
.toList()
..sort((a, b) => a.name.compareTo(b.name));
return LockCatalog(locks: locks);
}

static Future<LockCatalog> loadBundled() async =>
fromJsonString(await rootBundle.loadString('assets/lock_catalog.json'));
}
60 changes: 60 additions & 0 deletions apps/save-editor/lib/features/editor/domain/locks_models.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/// Result of `private.locks.list`.
///
/// A save records only which locks the player has ALREADY opened, so this is
/// the smaller half of the picture: the panel joins it against the bundled
/// lock catalog, which is what knows the other 200-odd locks that are still
/// shut. Carries an optional [error] (set by the notifier instead of throwing)
/// so the panel renders failures inline.
class LocksResult {
LocksResult({this.unlocked = const [], this.writable = const [], this.error})
: _folded = {for (final name in unlocked) name.toLowerCase()};

factory LocksResult.fromJson(Map<String, Object?> json) {
return LocksResult(
unlocked: (json['unlocked'] as List? ?? const [])
.whereType<String>()
.toList(growable: false),
writable: (json['writable'] as List? ?? const [])
.whereType<String>()
.toList(growable: false),
);
}

/// Every lock the player has opened, spelled as the save spells it. The
/// spelling matters for a name the catalog does not know: that row is
/// rendered and edited under the save's own name, not a folded copy.
final List<String> unlocked;

/// Lower-cased [unlocked], for membership tests. The save's set is an FName
/// set, so a catalog entry differing only in case is the same lock.
final Set<String> _folded;

/// Ops the core will accept for this save. Empty when the save carries no
/// editable lock set, which is what keeps the panel read-only instead of
/// offering a toggle the write path would refuse.
final List<String> writable;

final String? error;

bool get canSetUnlocked => writable.contains('private.locks.setUnlocked');

bool isUnlocked(String name) => _folded.contains(name.toLowerCase());
}

/// Pending lock change → `private.locks.setUnlocked`. Declarative: the state
/// one lock shall be in. Keyed by the lock name in the pending map, so
/// toggling a row back to its saved value drops the edit instead of stacking a
/// second one.
class LockSetUnlockedEdit {
const LockSetUnlockedEdit({required this.lock, required this.unlocked});

final String lock;
final bool unlocked;

Map<String, Object?> toEditJson() {
return {
'path': 'private.locks.setUnlocked',
'value': {'lock': lock, 'unlocked': unlocked},
};
}
}
63 changes: 63 additions & 0 deletions apps/save-editor/lib/features/editor/ui/area_labels.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:goresave/features/editor/domain/location_catalog.dart';
import 'package:goresave/l10n/app_localizations.dart';
import 'package:goresave/loc/game_lang.dart';

/// Our own name for an area the game itself does not name, or null when the
/// area is not one of them.
///
/// The catalog gives every area an English `label` and gives 18 of the 26 a
/// `locId` into the game's own strings. The other eight have no clean label
/// anywhere in the game's 43,851 ids — only quest titles, item names and
/// dialogue lines mention them — so their names are the editor's own and live
/// in the ARB like any other piece of UI text.
///
/// An explicit `switch` rather than "whatever the ARB happens to contain": an
/// area added to the catalog without a translation lands on `_` and is named by
/// `location_picker_dialog_test`, instead of silently rendering English inside
/// an otherwise German sidebar — which is the bug this table exists to close.
@visibleForTesting
String? appAreaLabel(String areaId, AppLocalizations l10n) => switch (areaId) {
'CV' => l10n.locationAreaCavalornValley,
'EF' => l10n.locationAreaEastForest,
'FT' => l10n.locationAreaFogTower,
'HC' => l10n.locationAreaTundra,
'IWM' => l10n.locationAreaIllegalWeedMixers,
'OA' => l10n.locationAreaOrcArena,
'OG' => l10n.locationAreaOrcGraveyard,
'SW' => l10n.locationAreaShipwreck,
_ => null,
};

/// Localized area name, in this order: the game's own notification string when
/// the catalog carries a loc id, then our [appAreaLabel] for the areas the game
/// does not name, and only then the generated English [LocationArea.label] — a
/// safety net for an area added to the catalog before anyone translated it,
/// never the normal outcome.
///
/// Shared by the location picker and the locks panel: both group by the same
/// area codes, and a second copy of the table above is exactly the drift this
/// function exists to prevent.
///
/// NOTE on German: for this `area_*` family the real string sits in the
/// `german` set and `german_new` is NULL — inverted versus the rest of the
/// game's text, where `german_new` wins. No special casing is needed because
/// [resolveGameText] walks `lang.locSets` in order and SKIPS empty/missing
/// sets, so `german_new` being absent falls through to `german` on its own.
String localizedAreaLabel(
String areaId,
LocationCatalog catalog,
Map<String, Map<String, String>> locCatalog,
GameLang lang,
AppLocalizations l10n,
) {
if (areaId.isEmpty) return l10n.locationAreaOther;
final area = catalog.areaById(areaId);
if (area == null) return areaId;
final locId = area.locId;
if (locId != null && locId.isNotEmpty) {
final localized = resolveGameText(locCatalog, locId, lang);
if (localized != null && localized.trim().isNotEmpty) return localized;
}
return appAreaLabel(areaId, l10n) ?? area.label;
}
Loading
Loading