Files
ReSkate/Extension/Profile/profile_update.h
T
AyeZeeBB 0b4996e27a Fix settings reverting: saves never refreshed the settings caches
Changing a setting in the game's menu did not stick. The camera went
back to high on leaving the menu, grind assist and lean exit stayed at
20 / on, the compass and scoring ticker kept their old state, and the
Experimental tab's "reset to default" returned to whatever was set at
launch.
b763631 gave the settings hooks per-thread caches of saved values that
look a value up again only when profile::Store::changes() advances.
Only whole-profile commits advanced it. Every single save (a setting,
an option, an outfit) goes through Store::Update::commit(), which did
not, so the caches answered with launch-time values for the rest of
the session. native_setting_get also wrote the cached value back over
the new one when the two differed. The saves themselves reached the
database, so profiles are intact.
- Update::commit() advances the counter through Store::note_change(),
  after value_ holds the new values and before the lock is released.
  Store::commit() and the constructor use it too.
- A cache hit needs the whole key text, not the key's address and first
  8 bytes. Many keys share those bytes (egf_enable..., egf_exp_...,
  CRAS_HasSeen...Tab), so a reused address could answer for another
  setting. Still no heap string, store lock or map walk on a hit.
- An outfit that is not loaded or not saved is logged as a warning with
  its reason and preset (local_cosmetic_loadout_rejected). It was a
  debug event, so a player's log never said why an outfit was refused.
  Routine cases stay at debug.
- dingosdk_profile_change_tests (with the park editor tests): each kind
  of save advances changes(), an unchanged save does not, and saved
  values keep their types across a restart.
2026-10-03 16:35:12 -05:00

113 lines
4.9 KiB
C++

#pragma once
#include "database_codec.h"
#include "profile_internal.h"
#include <set>
#include <type_traits>
namespace dingosdk::profile {
// Caller holds Store::mutex_. Edits copy only the affected record. Old map nodes
// are retained until SQLite commits; any exception restores them without allocation.
class Store::Update {
struct Undo {
const void* owner;
std::string key;
Undo(const void* owner_value, std::string key_value) : owner(owner_value), key(std::move(key_value)) {}
virtual ~Undo() = default;
virtual void apply() = 0;
virtual void rollback() noexcept = 0;
virtual bool changed() const = 0;
};
template<class Map> struct MapUndo final : Undo {
Map& map;
typename Map::node_type old, replacement;
typename Map::iterator current;
bool applied{};
MapUndo(Map& target, const std::string& key_value) : Undo(&target, key_value), map(target) {
Map staged;
auto found = map.find(this->key);
staged.emplace(this->key, found == map.end() ? typename Map::mapped_type{} : found->second);
replacement = staged.extract(staged.begin());
}
void apply() override {
old = map.extract(this->key);
current = map.insert(std::move(replacement)).position;
applied = true;
}
bool changed() const override { return old.empty() || old.mapped() != current->second; }
void rollback() noexcept override {
if (!applied) return;
map.erase(current);
if (!old.empty()) map.insert(std::move(old));
}
};
Store& store_;
std::vector<std::unique_ptr<Undo>> undo_;
std::map<std::string, std::set<std::string>, std::less<>> records_;
struct JsonEdit { Json* root; std::vector<std::string> path; };
std::vector<JsonEdit> json_edits_;
bool committed_{};
template<class Map> auto& edit(Map& map, const std::string& key) {
for (const auto& entry : undo_)
if (entry->owner == &map && entry->key == key) return map.at(key);
auto entry = std::make_unique<MapUndo<Map>>(map, key);
auto* operation = entry.get();
undo_.push_back(std::move(entry));
operation->apply();
return map.at(key);
}
void encode(Json& patch);
public:
explicit Update(Store& store) : store_(store) {}
Update(const Update&) = delete;
Update& operator=(const Update&) = delete;
~Update() {
if (!committed_) for (auto it = undo_.rbegin(); it != undo_.rend(); ++it) (*it)->rollback();
}
template<class Map> auto& record(Map& map, std::string_view key, std::string_view table) {
records_[std::string(table)].emplace(key);
return edit(map, std::string(key));
}
Json& json(Json& root, std::initializer_list<std::string_view> path, bool container = false) {
detail::require(path.size() != 0, "Empty profile update path");
JsonEdit tagged{&root, {}};
for (auto key : path) tagged.path.emplace_back(key);
json_edits_.push_back(std::move(tagged));
auto* node = &root;
std::size_t index{};
for (auto key : path) {
detail::require(node->is_object(), "Invalid profile update object");
const bool last = ++index == path.size();
if (!node->contains(key) || (last && !container)) {
auto& next = edit(node->items(), std::string(key));
if ((!last || container) && next.is_null()) next = Json::object();
node = &next;
} else node = &node->at(key);
}
if (container) detail::require(node->is_object(), "Invalid profile collection");
return *node;
}
void commit() {
if (std::none_of(undo_.begin(), undo_.end(), [](const auto& item) { return item->changed(); })) {
committed_ = true; return;
}
auto& value = store_.value_;
detail::require(value.revision != UINT64_MAX, "Profile revision overflow");
detail::require(value.bool_options.size() + value.play_events.size() + value.quests.size() +
value.entitlements.size() + value.neighborhood_ranks.size() <= detail::max_records,
"Too many local profile records");
Json patch = Json::object();
encode(patch);
auto metadata = database::singleton(store_.database_->document(), "profile_meta");
metadata[1] = std::to_string(value.revision + 1);
const auto metadata_key = storage::row_key(metadata, 1);
patch["profile_meta"][metadata_key] = std::move(metadata);
store_.database_->patch(std::move(patch));
++value.revision;
committed_ = true;
// Every setting, option and outfit is saved through here. Without this the settings
// hooks keep answering from the values they cached before the save.
Store::note_change();
}
};
}