#include "Engine/Core/Log/logging.h" #include "Engine/Game/Abi/native_data.h" #include "Extension/Profile/runtime_internal.h" #include "local_challenge_runtime.h" #include "Extension/Throwdowns/virtual_player_names.h" #include "local_entitlement_trigger_runtime.h" #include #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/engine.h" #include "Engine/Game/Build/20260929/local_challenge.h" namespace dingosdk::profile_runtime { // Decoded challenge entry/exit expressions and the native activity model catalog. // See analysis/local-challenges.md for layouts and lifetime/ownership evidence. ChallengeRuntime& challenge_runtime() { static auto* value = new ChallengeRuntime; return *value; } std::atomic_bool& hidden_challenges() { static std::atomic_bool value{}; return value; } std::string_view challenge_title_fallback(std::string_view key) { const auto& labels = challenge_runtime().title_fallbacks; const auto found = labels.find(key); return found == labels.end() ? std::string_view{} : found->second; } thread_local bool select_authored_challenge{}; thread_local ChallengeCompletionDelivery* challenge_completion_delivery{}; bool local_challenges_active() { return local_runtime().active.load(std::memory_order_acquire) && challenge_runtime().policy.enabled; } const profile::ChallengeDefinition* local_challenge(std::string_view id) { const auto& catalog = challenge_runtime().policy.catalog; const auto it = catalog.find(id); return it == catalog.end() ? nullptr : &it->second; } bool challenge_native_array(const void* wrapper, std::uintptr_t& data, std::uint32_t& count, std::uint32_t maximum) { return game::native_array(wrapper, data, count, maximum); } bool challenge_player_id(const void* wrapper, std::string& value) { std::uintptr_t pointer{}; std::uint8_t first{}; if (!read(reinterpret_cast(wrapper), pointer) || !read(pointer, first)) return false; // The local session has no authenticated backend player ID. Its valid // CString is empty, and must be preserved in the native callback payload. if (!first) { value.clear(); return true; } return identifier(wrapper, value); } bool challenge_native_strings(const void* wrapper, std::vector& values, std::uint32_t maximum, bool players ) { std::uintptr_t data{}; std::uint32_t count{}; if (!challenge_native_array(wrapper, data, count, maximum)) return false; std::set seen; for (std::uint32_t i = 0; i < count; ++i) { std::string id; const auto value = reinterpret_cast(data + i * 8); if (!(players ? challenge_player_id(value, id) : identifier(value, id)) || !seen.insert(id).second) return false; values.push_back(std::move(id)); } return true; } void* challenge_request(bool ending, void* out, const void* id_value, const void* player_value, const void* values, const void* callback) { auto& r = challenge_runtime(); auto& s = local_runtime(); const auto original = ending ? r.f.end : r.f.begin; if (!local_challenges_active()) return original(out, id_value, player_value, values, callback); PreserveError preserve; std::lock_guard lock(s.native_mutex); PendingChallenge request; request.ending = ending; if (!identifier(id_value, request.id) || !local_challenge(request.id)) { if (r.forwarded_requests++ < 16) dingosdk::logging::event(dingosdk::logging::Channel::progression, dingosdk::Json{{"event", "local_challenge_native_forward"}, {"ending", ending}, {"id", request.id}, {"selected", r.selected}}.dump().c_str()); return original(out, id_value, player_value, values, callback); } game::NativeDelegateGuard retained; game::native_data().values.copy_delegate(&retained.value, callback); request.callback = retained.value; struct RequestReference { std::uintptr_t value{}; ~RequestReference() { if (value) challenge_runtime().f.release_request(&value); } } ticket; try { if (!challenge_player_id(player_value, request.player)) throw std::runtime_error("Invalid challenge player"); if (ending) { std::uintptr_t data{}; std::uint32_t count{}; if (!challenge_native_array(values, data, count, 16) || !count) throw std::runtime_error("Invalid challenge participants"); bool found{}; std::set unique; for (std::uint32_t i = 0; i < count; ++i) { std::string player; std::vector goals; const auto entry = data + i * 16; if (!challenge_player_id(reinterpret_cast(entry), player) || !unique.insert(player).second || !challenge_native_strings(reinterpret_cast(entry + 8), goals, 256)) throw std::runtime_error("Invalid challenge participant result"); request.players.push_back(player); if (player == request.player) { found = true; request.goals = std::move(goals); } } const auto attempt = r.attempts.find(request.id); if (!found || attempt == r.attempts.end()) throw std::runtime_error("Challenge has not begun locally"); request.attempt = attempt->second; // ServerMpActivityBase's leave/remove/participant handlers end the leaver's attempt // (their entry plus the host's). With a virtual leaver the local attempt goes on. std::uintptr_t resource{}; std::uint32_t graph{}; if (executing_expression && read(executing_expression + 0x38, resource) && resource) read(resource + 0x10, graph); constexpr std::array leave_graphs{0xad0980ba, 0x93a7f66f, 0x973edf07, 0xfc3cbd3a}; request.virtual_leave = std::ranges::find(leave_graphs, graph) != leave_graphs.end() && std::ranges::any_of(request.players, [](const std::string &p) { return multiplayer::is_virtual_eid(p); }); } else if (!challenge_native_strings(values, request.players, 16, true)) { throw std::runtime_error("Invalid challenge participants"); } // SendEndAmpEvent checks the *start request reference* at +b8 before // submitting results. A success callback with a null return reference // leaves that guard false forever. Use the same typed, reference-counted // cancellation ticket as the native begin/end endpoints. Its factory // allocates only local state; it does not contact a backend. std::uintptr_t allocator{}; if (!read(s.base + addr::engine::ui_allocator, allocator) || !allocator) throw std::runtime_error("Challenge request allocator unavailable"); r.f.create_request(&ticket.value, allocator); if (!ticket.value) throw std::runtime_error("Challenge request allocation failed"); } catch (const std::exception& error) { request.failed = true; dingosdk::logging::event(dingosdk::logging::Channel::progression, dingosdk::Json{{"event", "local_challenge_request_rejected"}, {"id", request.id}, {"reason", error.what()}}.dump().c_str()); } // No native pointer into the expression stack survives this call. Retain // the VM delegate, then commit and acknowledge on the client update thread. r.pending.push_back(std::move(request)); retained.value = 0; // Transfer the factory's owned reference to the VM. The game retains it in // StartEventAmpCallback/EndEventAmpCallback and releases it normally. *static_cast(out) = ticket.value; ticket.value = 0; return out; } void* challenge_begin_hook(void* out, const void* id, const void* player, const void* values, const void* callback) { return challenge_request(false, out, id, player, values, callback); } void* challenge_end_hook(void* out, const void* id, const void* player, const void* values, const void* callback) { return challenge_request(true, out, id, player, values, callback); } bool challenge_available_hook(const void* value) { if (local_challenges_active()) { PreserveError preserve; std::string id; if (identifier(value, id)) if (const auto* entry = local_challenge(id)) return entry->available && !hidden_challenges().load(std::memory_order_acquire); } return challenge_runtime().f.available(value); } std::uint64_t challenge_lookup_hook(std::uintptr_t owner, const void* value) { // Native SelectActivity returns its "populate from authored prefab" flag // when the catalog lookup misses. Keep that path for complete goal/rule data. if (select_authored_challenge) return 0; return challenge_runtime().f.lookup(owner, value); } bool challenge_select_hook(std::uintptr_t owner, const void* value) { auto& r = challenge_runtime(); std::string id; if (!local_challenges_active() || !identifier(value, id) || !local_challenge(id)) return r.f.select(owner, value); std::lock_guard lock(local_runtime().native_mutex); struct Restore { bool old{select_authored_challenge}; ~Restore() { select_authored_challenge = old; } } restore; select_authored_challenge = true; r.selected = id; return r.f.select(owner, value); } std::uintptr_t challenge_owner() { const auto base = local_runtime().base; std::uintptr_t context{}, vtable{}, backlink{}, manager{}, buckets{}; std::uint32_t offset{}, count{}; std::uint8_t ready{}; if (!read(base + addr::local_challenge::challenge_context, context) || !context || !read(base + addr::local_challenge::challenge_owner_offset, offset) || offset > 0x1000000) return 0; const auto owner = context + offset; if (!read(owner, vtable) || vtable != base + addr::local_challenge::challenge_owner_vtable || !read(owner + 0x18, backlink) || backlink != context || !read(owner + 0x268, ready) || ready != 1 || !read(owner + 0x1a8, manager) || !manager || !read(owner + 0x38, buckets) || !buckets || !read(owner + 0x40, count) || !count || count > 0x100000) return 0; return owner; } // Called for every catalog entry on each catalog poll: peeked (no system call per read). std::uint64_t challenge_map_lookup(std::uintptr_t map, const std::string& id) { std::uintptr_t buckets{}, node{}; std::uint32_t count{}; if (!memory::peek(map, buckets) || !memory::peek(map + 8, count) || !count || count > 0x100000 || !memory::peek(buckets + (game::native_name_hash(id) % count) * 8, node)) return 0; std::string key; for (unsigned visited = 0; node && visited < 2048; ++visited) { std::uint64_t value{}; if (!identifier(reinterpret_cast(node), key) || !memory::peek(node + 8, value)) return 0; if (key == id) return value; if (!memory::peek(node + 16, node)) return 0; } return 0; } bool challenge_map_type_contract(std::uintptr_t base) { std::uint16_t counts_size{}, activity_size{}; struct Field { std::uint64_t hash, offset; std::uintptr_t type; } field{}; return read(base + addr::local_challenge::challenge_counts_size, counts_size) && counts_size == sizeof(ChallengeCounts) && read(base + addr::local_challenge::activity_data_size, activity_size) && activity_size == 0x60 && read(base + addr::local_challenge::activity_criteria_field, field) && field.hash == 0x571954a7 && field.offset == 0x30 && field.type == base + addr::engine::uint64_type; } void challenge_counts_hook(const void* neighborhood, bool visible_only, ChallengeCounts* output) { // The retail function returns without writing when its owner/index is not // ready or the neighborhood has no entry. Never expose VM stack garbage. if (output && local_challenges_active()) *output = {}; challenge_runtime().f.counts(neighborhood, visible_only, output); } bool hydrate_challenge_neighborhoods(std::uintptr_t owner) { auto& r = challenge_runtime(); std::uintptr_t buckets{}; std::uint32_t bucket_count{}; if (!read(owner + 0x100, buckets) || !buckets || !read(owner + 0x108, bucket_count) || !bucket_count || bucket_count > 0x100000) return false; std::map, std::less<>> groups; for (const auto id : profile::neighborhood_ids) groups[std::string(id)]; for (const auto& [id, entry] : r.policy.catalog) { if (entry.neighborhood.empty()) continue; auto& handles = groups[entry.neighborhood]; const auto owned = r.catalog_activities.find(id); if (owned != r.catalog_activities.end() && owned->second && challenge_map_lookup(owner + 0x38, id) == owned->second) handles.push_back(owned->second); } bool changed{}; for (const auto& [id, handles] : groups) { const auto* key = id.c_str(); // Native find-or-insert clones the borrowed key and owns the vector. // Its result is {node, bucket, inserted}; the node's vector starts at +8. std::array result{}; r.f.index_insert(owner + 0x100, result.data(), &key); if (!result[0]) continue; changed |= (result[2] & 0xff) != 0; const auto vector = result[0] + 8; std::uintptr_t begin{}, end{}, capacity{}; if (!read(vector, begin) || !read(vector + 8, end) || !read(vector + 16, capacity) || end < begin || capacity < end || capacity - begin > 0x20000 || (end - begin) % 8 || (!begin && (end || capacity))) continue; std::vector current((end - begin) / 8); if (!current.empty() && !read_bytes(begin, current.data(), current.size() * 8)) continue; std::set present(current.begin(), current.end()); for (const auto handle : handles) { if (present.insert(handle).second) { r.f.index_append(vector, &handle); changed = true; } } } return changed; } void hydrate_challenge_catalog(std::uintptr_t owner) { auto& r = challenge_runtime(); auto& n = game::native_data().models; const auto base = local_runtime().base; std::uintptr_t manager{}, context{}; if (!read(owner + 0x1a8, manager) || !read(owner + 0x18, context)) return; if (r.last_owner != owner) { r.catalog_groups.clear(); r.catalog_activities.clear(); r.catalog_goals.clear(); r.changed_groups.clear(); r.map_progress_revision = UINT64_MAX; } const auto create = [&](std::uintptr_t type, const void* data) { // The real activity catalog's allocation expands to this // anonymous allocation. A named (0, 1) allocation instead reuses one // model per type and makes every challenge display the last record. const auto handle = n.create(manager, base + type, UINT32_MAX, UINT32_MAX, false, 1); if (!handle) throw std::runtime_error("Challenge model creation failed"); // Publish returns whether a value changed, not whether assignment // succeeded. A fresh empty criterion group already equals its default. n.publish(manager, handle, base + type, data); return handle; }; const auto insert = [&](std::size_t offset, const std::string& id, std::uint64_t handle) { const char* key = id.c_str(); std::array result{}; r.f.insert(owner + offset, result.data(), 0, &key, &handle); }; ChallengeEmptyArray empty; const auto array = reinterpret_cast(&empty.element); const char* blank = ""; std::size_t added{}; bool refreshed{}; const auto snapshot_shared = local_runtime().store->shared_snapshot(); const auto& snapshot = *snapshot_shared; const auto hidden = snapshot.bool_options.find(profile::hide_challenges_option); const bool visible = hidden == snapshot.bool_options.end() || !hidden->second; const auto populate_group = [&](const profile::ChallengeDefinition& entry, std::uint64_t existing) { // A present group with zero criteria means completed to the retail count // function. Absent metadata must stay an absent group until learned. if (entry.goals.empty()) return std::uint64_t{}; ChallengeArray required, optional; auto& handles = r.catalog_goals[entry.id]; handles.clear(); const auto completed = profile::challenge_completed_criteria(snapshot, entry.id); for (const auto& goal : entry.goals) { // Map-only criterion values. Actual playable rules/thresholds still // come from the selected authored prefab, never cached rewards. const ChallengeCriterionValue value{0, goal.id.c_str(), 0, array, 0, goal.description_key.c_str(), goal.title_key.c_str(), goal.feed_key.c_str(), goal.short_description_key.c_str(), 0, std::find(completed.begin(), completed.end(), goal.id) != completed.end(), goal.optional, {}}; const auto handle = create(addr::local_challenge::criterion_type, &value); auto& list = goal.optional ? optional : required; list.values[list.count++] = handle; handles.emplace_back(goal.id, handle); } struct Group { const char* title; const std::uint64_t* criteria; const char* other_title; const std::uint64_t* other_criteria; } group{blank, required.values.data(), blank, optional.values.data()}; if (existing) { n.publish(manager, existing, base + addr::local_challenge::criterion_group_type, &group); return existing; } return create(addr::local_challenge::criterion_group_type, &group); }; for (const auto& [id, entry] : r.policy.catalog) { if (const auto activity = challenge_map_lookup(owner + 0x38, id)) { if (r.catalog_activities.contains(id) && r.catalog_activities.at(id) == activity && r.catalog_groups.contains(id) && r.changed_groups.contains(id)) { auto& group = r.catalog_groups.at(id); group = populate_group(entry, group); // Learning the first goals creates the previously absent group. // Field 14 is ActivityData's UInt64 criterion-group handle. const auto field = game::native_data().models.field(manager, activity, 14, UINT32_MAX, false); if (field) n.publish(manager, field, base + addr::engine::uint64_type, &group); const auto value = n.value(manager, activity, 0, 0); std::uint64_t published{}; if (read(value + 0x30, published) && published == group) r.changed_groups.erase(id); refreshed = true; } continue; } auto type = challenge_map_lookup(owner + 0x60, entry.type); if (!type) { static const std::map types{{"OTS", 1}, {"Slam", 2}, {"Line", 3}, {"Session", 4}, {"Stunt", 5}, {"OTL", 6}, {"Speedline", 7}}; struct Type { const char* name; std::uint32_t type, padding{}; } data{entry.type.c_str(), types.at(entry.type)}; type = create(addr::local_challenge::activity_kind_type, &data); insert(0x60, entry.type, type); } const auto criteria = populate_group(entry, 0); r.catalog_groups[id] = criteria; // Reflected field records (0x82dc240..): +0x18 SeasonId, +0x38 ActivityTitle and +0x40 // Description (the map card and details page show them localized). struct Data { std::uintptr_t array; std::uint64_t type; const char* id; const char* season; std::int64_t expires{}; std::uintptr_t added_array; std::uint64_t criteria; const char* title; const char* description; const char* neighborhood; std::uint32_t priority{1}; std::uint8_t added_flag{}, is_new{}, visible{1}, flag{}, available{}, extra{}, another_flag{}; std::uint8_t padding[5]{}; } data{array, type, id.c_str(), blank, 0, array, criteria, entry.title_key.empty() ? id.c_str() : entry.title_key.c_str(), entry.description_key.c_str(), entry.neighborhood.c_str(), 1, 0, 0, static_cast(visible), 0, static_cast(visible && entry.available)}; // Current reflected ActivityData is 60h, not the older 58h record. // Both native arrays need valid count prefixes even while empty. static_assert(sizeof(Data) == 0x60 && offsetof(Data, id) == 0x10 && offsetof(Data, added_array) == 0x28 && offsetof(Data, criteria) == 0x30 && offsetof(Data, available) == 0x58); const auto activity = create(addr::local_challenge::activity_data_type, &data); insert(0x38, id, activity); r.catalog_activities[id] = activity; ++added; } // The native count/list functions consume a separate neighborhood index, // not the ID catalog. Reuse the real insertion/vector helpers, preserve // foreign entries and deduplicate handles on every retry. refreshed |= hydrate_challenge_neighborhoods(owner); // Restored and newly earned results must reach every map entry, including // challenges that have not been selected since launching the game. if (r.map_progress_revision != snapshot.revision || refreshed) { game::ModelWriteLock model_lock(manager); for (const auto& [id, entry] : r.policy.catalog) { const auto handle = challenge_map_lookup(owner + 0x38, id); if (!handle) continue; const auto value = n.value(manager, handle, 0, 0); const auto set_flag = [&](std::size_t offset, unsigned index, bool next) { std::uint8_t current{}; if (!read(value + offset, current) || static_cast(current) == next) return; const auto field = game::native_data().models.field(manager, handle, index, UINT32_MAX, false); if (field) { n.publish(manager, field, base + addr::engine::bool_type, &next); refreshed = true; } }; // Reflected ActivityData visibility and availability fields. Keep // goal models and completion handling alive while markers are hidden. set_flag(0x56, 12, visible); set_flag(0x58, 7, visible && entry.available); } for (const auto& [id, goals] : r.catalog_goals) { const auto saved = profile::challenge_completed_criteria(snapshot, id); for (const auto& [goal, handle] : goals) { const auto value = n.value(manager, handle, 0, 0); std::uint8_t was{}; const bool complete = std::find(saved.begin(), saved.end(), goal) != saved.end(); if (!read(value + 0x4c, was) || static_cast(was) == complete) continue; const auto field = game::native_data().models.field(manager, handle, 11, UINT32_MAX, false); if (field) { n.publish(manager, field, base + addr::engine::bool_type, &complete); refreshed = true; } } } r.map_progress_revision = snapshot.revision; } if (added || refreshed) { // Same ActivityListUpdated notification as the real catalog callback. std::uint64_t payload{}; const std::array options{0, 1, 0}; r.f.dispatch(context, base + addr::local_challenge::activity_list_updated_type, &payload, options.data(), 0); dingosdk::logging::event(dingosdk::logging::Channel::progression, dingosdk::Json{{"event", "local_challenge_catalog"}, {"added", added}, {"progress_refreshed", refreshed}, {"owner", owner}}.dump().c_str()); } r.last_owner = owner; } void update_challenge_catalog() { auto& r = challenge_runtime(); if (!local_challenges_active() || GetTickCount64() < r.next_catalog_poll) return; r.next_catalog_poll = GetTickCount64() + 1000; const auto owner = challenge_owner(); if (owner) hydrate_challenge_catalog(owner); } void update_selected_challenge_progress(bool learn ) { auto& r = challenge_runtime(); auto& s = local_runtime(); auto& n = game::native_data().models; if (!local_challenges_active() || GetTickCount64() < r.next_progress_poll) return; r.next_progress_poll = GetTickCount64() + 250; const auto owner = challenge_owner(); if (!owner) return; std::uintptr_t manager{}; std::uint64_t wrapper{}, selected{}, group{}; if (!read(owner + 0x1a8, manager) || !read(owner + 0x148, wrapper) || !wrapper) return; std::string id; std::vector authored; std::set unique; { game::ModelWriteLock model_lock(manager); if (!read(n.value(manager, wrapper, 0, 0), selected) || !selected) return; const auto data = n.value(manager, selected, 0, 0); if (!identifier(reinterpret_cast(data + 0x10), id) || !local_challenge(id) || !read(data + 0x30, group) || !group) return; const auto& definition = *local_challenge(id); const auto fill_text = [&](std::uint64_t model, std::uintptr_t address, unsigned index, const std::string& key, std::string_view placeholder = {}) { if (key.empty()) return; std::uintptr_t pointer{}; std::uint8_t first{}; if (!read(address, pointer)) return; std::string current; if (pointer && (!read(pointer, first) || (first && !identifier(reinterpret_cast(address), current)))) return; // Keep authored text when present; only repair empty/ID placeholders. if (!current.empty() && current != placeholder) return; const auto field = game::native_data().models.field(manager, model, index, UINT32_MAX, false); const char* text = key.c_str(); if (field) n.publish(manager, field, s.base + addr::engine::string_type, &text); }; fill_text(selected, data + 0x40, 2, definition.description_key, id); const auto saved = s.store->completed_challenge_criteria(id); const auto group_data = n.value(manager, group, 0, 0); for (const auto offset : {8U, 24U}) { std::uintptr_t entries{}; std::uint32_t count{}; if (!challenge_native_array(reinterpret_cast(group_data + offset), entries, count, 256)) return; for (std::uint32_t i = 0; i < count; ++i) { std::uint64_t handle{}; std::string goal; std::uint8_t completed{}; if (!read(entries + i * 8, handle) || !handle) return; const auto value = n.value(manager, handle, 0, 0); if (!identifier(reinterpret_cast(value + 8), goal) || !read(value + 0x4c, completed) || !unique.insert(goal).second || authored.size() >= 256) return; profile::ChallengeGoal observed{goal, offset == 24}; const auto cached = std::find_if(definition.goals.begin(), definition.goals.end(), [&](const auto& entry) { return entry.id == goal; }); const auto text = [&](std::size_t offset, unsigned index, std::string& destination, const std::string* fallback) { // Learn authored localization keys as well as IDs so uncached // activities retain their descriptions on the map after restart. std::string key; if (identifier(reinterpret_cast(value + offset), key)) destination = std::move(key); else if (fallback) { destination = *fallback; fill_text(handle, value + offset, index, destination); } }; const auto* metadata = cached == definition.goals.end() ? nullptr : &*cached; text(0x30, 1, observed.title_key, metadata ? &metadata->title_key : nullptr); text(0x38, 3, observed.feed_key, metadata ? &metadata->feed_key : nullptr); text(0x40, 2, observed.short_description_key, metadata ? &metadata->short_description_key : nullptr); text(0x28, 4, observed.description_key, metadata ? &metadata->description_key : nullptr); authored.push_back(std::move(observed)); if (completed || std::find(saved.begin(), saved.end(), goal) == saved.end()) continue; // Native progress hydration uses GetModelField here. // FindModel instead expects type metadata, not a model handle. const auto field = game::native_data().models.field(manager, handle, 11, UINT32_MAX, false); const bool yes = true; if (field) n.publish(manager, field, s.base + addr::engine::bool_type, &yes); } } } if (!learn) return; // Wait for the same complete list on two polls, then retain the current // authored identities. This also covers newly installed uncached challenges. // Disk IO happens after releasing the native model lock. if (r.observed_id != id || r.observed_goals != authored) { r.observed_id = id; r.observed_goals = std::move(authored); return; } if (!authored.empty() && local_challenge(id)->goals != authored && s.store->remember_challenge_goals(id, authored)) { r.policy.catalog.at(id).goals = std::move(authored); r.changed_groups.insert(id); r.next_catalog_poll = 0; dingosdk::logging::event(dingosdk::logging::Channel::progression, dingosdk::Json{{"event", "local_challenge_goals_learned"}, {"id", id}, {"goals", r.policy.catalog.at(id).goals.size()}}.dump().c_str()); } } void deliver_local_challenge_completion(std::uintptr_t vm, std::uint32_t pc) noexcept try { auto* delivery = challenge_completion_delivery; if (!delivery || delivery->delivered || pc || !local_challenges_active()) return; PreserveError preserve; // EndChallengeV2Callback sends OnAmpActivityEndRecieved_Global using the // participant's backend EID. The native sender rejects an empty EID // before sending anything. Only the local empty-EID callback gets this // client-context delivery, after its normal interpreter has returned. constexpr std::array expected{64, 72, 46, 123, 7, 0, 1, 327689, 1441792, 66560}; constexpr std::array event_guid{ 0x24,0xc2,0x5b,0xb4,0x9f,0x0d,0x03,0x73,0x28,0x6a,0xeb,0x83,0x76,0xeb,0xe2,0xb6}; std::uintptr_t resource{}, instance{}, instance_resource{}, type{}, metadata{}, context{}; std::uint32_t key{}, hash{}, realm_offset{}, realm{}; std::uint16_t flags{}, size{}; std::int32_t code{}; std::array layout{}; std::array pages{}; std::array guid{}; std::string reason; if (!read(vm + 0x38, resource) || !read(resource + 0x10, key) || key != 0x388b3076) return; delivery->blocked_at = "graph_layout"; if (!read(resource + 0x20, layout) || layout != expected || !read(vm + 0x30, instance) || !read(instance, instance_resource) || instance_resource != resource) return; delivery->blocked_at = "event_metadata"; // The interpreter places the page table after the graph's aligned frame size // (resource +20): 40h for this callback, unlike SendEndAmpEvent's 60h. const auto page_offset = (layout[0] + 15U) & ~15U; if (!read(instance + page_offset, pages) || !read(pages[0] + 16, type) || !read(type, metadata) || !read(metadata, hash) || hash != 619632508U || !read(metadata + 4, flags) || (flags & 0x3e0) != 0x40 || !read(metadata + 6, size) || size != 16 || !read(metadata + 8, guid) || guid != event_guid) return; delivery->blocked_at = "event_payload"; if (!read(pages[2], code) || code != delivery->error_code || !challenge_player_id(reinterpret_cast(pages[2] + 8), reason)) return; delivery->blocked_at = "client_context"; const auto owner = challenge_owner(); if (!owner || !read(owner + 0x18, context) || !context || !read(local_runtime().base + addr::engine::context_type_offset, realm_offset) || realm_offset > 0x1000000 || !read(context + realm_offset, realm) || realm != 0xbf0f9789) return; // Publish copies the callback's original ErrorCode/Reason into the native // event queue. Do not synthesize reward data or retain VM page pointers. const std::array options{0, 1, 0}; delivery->delivered = true; challenge_runtime().f.dispatch(context, type, reinterpret_cast(pages[2]), options.data(), 0); dingosdk::logging::event(dingosdk::logging::Channel::progression, dingosdk::Json{{"event", "local_challenge_completion_delivered"}, {"error_code", code}}.dump().c_str()); } catch (...) { dingosdk::logging::event(dingosdk::logging::Channel::progression, "{\"event\":\"local_challenge_completion_delivery_failed\"}"); } void update_challenge_requests() { auto& r = challenge_runtime(); auto& s = local_runtime(); if (!local_challenges_active()) return; auto pending = std::move(r.pending); r.pending.clear(); for (auto& request : pending) { game::NativeDelegateGuard retained; retained.value = request.callback; try { if (request.failed) throw std::runtime_error("Invalid local challenge request"); if (request.ending && request.virtual_leave) { // Only a virtual participant left: the local attempt is still running. std::erase_if(request.players, [](const std::string &p) { return !multiplayer::is_virtual_eid(p); }); } else if (request.ending) s.store->finish_challenge(request.id, request.attempt, request.goals); else { request.attempt = s.store->begin_challenge(request.id); r.attempts[request.id] = request.attempt; } } catch (const std::exception& error) { request.failed = true; dingosdk::logging::event(dingosdk::logging::Channel::progression, dingosdk::Json{{"event", "local_challenge_save_failed"}, {"id", request.id}, {"reason", error.what()}}.dump().c_str()); } if (request.ending && !request.failed && !request.virtual_leave) { r.next_catalog_poll = 0; r.next_progress_poll = 0; try { update_selected_challenge_progress(false); } catch (...) { dingosdk::logging::event(dingosdk::logging::Channel::progression, "{\"event\":\"local_challenge_progress_refresh_failed\"}"); } } alignas(8) std::array error{}; if (request.failed) r.f.error(error.data(), 0xc5365a61, "ReSkate could not save challenge progress"); else s.success(error.data()); struct Release { void* error; ~Release() { local_runtime().destroy_string(error); } } release{error.data()}; ChallengeCompletionDelivery delivery; if (retained.value) { if (request.ending) { // Native callback receives Array with a count prefix. struct Players { std::uint32_t capacity{16}, count{}; std::array values{}; } players; players.count = static_cast(request.players.size()); for (std::size_t i = 0; i < request.players.size(); ++i) players.values[i] = request.players[i].c_str(); const auto values = players.values.data(); // The local player's own result: its empty EID, beside any virtual coop participants. const bool local_delivery = request.player.empty() && !request.virtual_leave && std::ranges::count(request.players, std::string{}) == 1 && std::ranges::all_of(request.players, [](const std::string &p) { return p.empty() || multiplayer::is_virtual_eid(p); }); std::memcpy(&delivery.error_code, error.data() + 32, 4); ChallengeCompletionScope scope(local_delivery ? &delivery : nullptr); r.f.invoke_end(&retained.value, error.data(), &values); if (local_delivery && !delivery.delivered) dingosdk::logging::event(dingosdk::logging::Channel::progression, dingosdk::Json{{"event", "local_challenge_completion_blocked"}, {"id", request.id}, {"stage", delivery.blocked_at}}.dump().c_str()); } else game::native_data().values.invoke(&retained.value, error.data()); } dingosdk::logging::event(dingosdk::logging::Channel::progression, dingosdk::Json{{"event", request.ending ? "local_challenge_ended" : "local_challenge_began"}, {"id", request.id}, {"attempt", request.attempt}, {"goals", request.goals}, {"players", request.players}, {"virtual_leave", request.virtual_leave}, {"callback_present", retained.value != 0}, {"local_delivery", delivery.delivered}, {"success", !request.failed}}.dump().c_str()); } } void initialize_challenge_functions(std::uintptr_t base) { auto& r = challenge_runtime(); r.policy = profile::challenge_policy(*local_runtime().store->shared_snapshot()); hidden_challenges().store(local_runtime().store->bool_option(profile::hide_challenges_option).value_or(false), std::memory_order_release); r.title_fallbacks.clear(); // A challenge whose title the game cannot translate (retail had it from the backend) shows // its id made readable: "Plot-009-OTS-01" -> "Plot 9 OTS 1", "OTSChallenge001" -> "OTS Challenge 1". const auto readable = [](std::string_view id) { std::string title; const auto is_upper = [](char c) { return c >= 'A' && c <= 'Z'; }; const auto is_lower = [](char c) { return c >= 'a' && c <= 'z'; }; const auto is_digit = [](char c) { return c >= '0' && c <= '9'; }; for (std::size_t i = 0; i < id.size(); ++i) { const char c = id[i] == '_' || id[i] == '-' ? ' ' : id[i]; const char previous = title.empty() ? ' ' : title.back(); // A word starts at a letter-digit change and at the capital that begins "Challenge" in "OTSChallenge". const bool boundary = c != ' ' && previous != ' ' && ((is_digit(c) != is_digit(previous)) || (is_upper(c) && is_upper(previous) && i + 1 < id.size() && is_lower(id[i + 1])) || (is_upper(c) && is_lower(previous))); if (boundary) title += ' '; // Leading zeros of a number: "009" -> "9". if (c == '0' && (title.empty() || title.back() == ' ') && i + 1 < id.size() && is_digit(id[i + 1])) continue; if (c != ' ' || (!title.empty() && title.back() != ' ')) title += c; } while (!title.empty() && title.back() == ' ') title.pop_back(); return title; }; for (const auto& [id, entry] : r.policy.catalog) { std::string title = readable(id); r.title_fallbacks.emplace(id, title); if (!entry.title_key.empty()) r.title_fallbacks.emplace(entry.title_key, std::move(title)); } r.f.invoke_end = reinterpret_cast(base + challenge_end_invoke_contract.rva); r.f.error = reinterpret_cast(base + challenge_error_contract.rva); r.f.index_insert = reinterpret_cast(base + challenge_index_insert_contract.rva); r.f.index_append = reinterpret_cast(base + challenge_index_append_contract.rva); r.f.insert = reinterpret_cast(base + challenge_insert_contract.rva); r.f.dispatch = reinterpret_cast(base + challenge_dispatch_contract.rva); r.f.create_request = reinterpret_cast(base + challenge_request_create_contract.rva); r.f.release_request = reinterpret_cast(base + object_release_contract.rva); } }