mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-13 14:47:39 +00:00
* feat(addons): retail-like /reload — new addons and new files load Two engine limitations kept anything created after boot from loading until a client restart: 1. File visibility: every relative-path read resolves through a loose-file hash index built once at boot (FUN_00646EA0, latched); the archive layer's live disk probe is dead code (DAT_00865B44 ships 1, boot clears it) and writes never register. New files - including a new addon's TOC and first-ever SavedVariables - were invisible until restart. 2. Registry membership: the addon registry is built once at login; new folders are never walked. Addons::Rescan runs per /reload from the ModuleAutoRegister callback (before the AddOns.txt re-read, progress count, and load pass) and: - re-indexes Interface\AddOns + WTF\Account with the engine's own dedup-safe boot indexer (FUN_VFS_INDEX_SUBTREE) - new files in existing addons then load natively, since the per-addon loader re-reads the TOC from disk on every load pass; a newly installed addon's first SV save now also survives /reload; - replays login scan walk #2 verbatim (FUN_ADDON_SCAN_DISK_DIRS with the engine's own dir-to-parser callback) so new folders register as complete normal entries via the dedup-guarded TOC parser; - mirrors the two structures the login scan would have filled: reverse-LoadWith links (tail-loop grow+append, restricted to pairs involving a new entry - the engine's lists have no dedup) and the flat GetNumAddOns display array (FUN_0051DA70 phase 2: filter byte +0x29, engine grow + qsort comparator). Append-only by design: no teardown, no eviction, no mutation of existing entries - the prior hot-reload attempt died re-running the login-only FUN_ADDON_INIT teardown mid-reload. Still login-only (no safe mechanism): ## metadata edits of already-registered addons and folder deletions. Also centralizes Embedded.cpp's addon-entry field constants into Offsets.h now that a second module reads them. * refactor(addons): centralize the registry list walk; record dedup-guard probe The intrusive linked-list walk (head at [VAR_ADDON_LIST_HEAD], next at entry + [VAR_ADDON_LIST_CTRL] + 4, low-bit-1/NULL sentinel) was implemented twice -- a template in Rescan.cpp and an inline loop in Embedded.cpp's FinalizeEmbeddedEntry. Move it to addons/Registry.h as Addons::ForEachEntry, with a void- or bool-returning visitor (false stops early). The early stop is load-bearing for Embedded: re-linking the found entry to the head invalidates the current next-pointer, so the walk must not continue past it -- the shared helper makes that contract explicit. Also record the TOC-parser dedup-guard verification in Rescan.cpp's scan-call comment: the guard is the parser's FIRST block (hash lookup + name compare, early return before the TOC path is even built), and both the Storm hash (uppercases a-z, folds '/'->'\') and the SStrCmpI compare are case-insensitive -- so the per-/reload scan replay can never re-read or mutate a registered entry's ## metadata, and a case-renamed folder matches its existing entry instead of registering a duplicate. * feat(addons): /reload re-parses edited ## metadata and drops deleted addons The TOC parser's dedup guard makes a registered entry's ## metadata read-once: an edited ## SavedVariables: / ## Dependencies: / any ## line never re-parsed until re-login, and a deleted folder's entry lingered in the addon list. Both were documented login-only limits of the retail-like /reload feature. Rescan's new step 1.5 closes them with evict + re-register, all engine machinery: - Change detection: FNV-1a over the TOC's ## lines only (BOM skipped like the parser, line terminators excluded), read through the hooked FUN_FILE_READ so flavor selection is hashed exactly as the parser reads it. File-reference edits never trigger the evict path -- the load pass re-reads those natively. Baselines seed at login and for newly registered folders in the same Run(), so an edit is always diffed against what was actually parsed. - Evict: scrub the victim from every other entry's reverse-LoadWith list (the one reference the destructor cannot clean -- the loader's post-ADDON_LOADED loop dereferences those entries), then call the engine's own complete per-entry destructor FUN_ADDON_ENTRY_DESTROY (0x005200A0, __stdcall, RET 4; vtable slot 0 of the registry node factory at [0x00BE1B60], dispatched per entry by the login teardown). Verified by decompile: frees every owned allocation (name, dep/SV arrays + buffers, reverse-LoadWith buffer, the ## metadata table) and self-unlinks from both the intrusive list (FUN_00520A60, no-ops when already unlinked) and the hash bucket chain. - Re-register: feed the name back to FUN_TOC_PARSER -- the dedup guard now misses and the entry rebuilds from the edited TOC exactly like a fresh registration. Refreshed entries join the existing FixupReverseLoadWith/RebuildDisplayArray fix-ups; the refresh runs before the before-snapshot so an address-reusing allocator cannot make the added-diff double-count or miss one. Any eviction forces the display-array rebuild (it holds the dead entry's freed name pointer until then). Victims are collected during the walk and processed after it -- the dtor zeroes the link node under the iterator. - Deletion: an unreadable TOC evicts without re-registering; a folder the scan still finds simply re-registers the same reload. Exempt: !!!ClassicAPI (Addons::Embedded owns its entry) and ## Secure: / SMSG-managed entries (the parser cannot restore packet-delivered state; new OFF_ADDON_ENTRY_SECURE = 0x28, parser write site verified). Verified in-game: ## X-Test metadata edit picked up across /reload (GetAddOnMetadata returns the new value), SavedVariables list edit takes effect, folder deletion drops the entry from GetNumAddOns, folder restore re-registers it, and a no-change /reload is a pure hash-compare no-op. * fix(addons): address review — hash commit timing, evict flag, README row - README: the /reload row said `##` metadata edits still need a re-login — stale from before the metadata-refresh step landed. Now matches docs/API.md: edits take effect, deleted folders drop from the addon list. - Rescan: commit g_metaHash only after the re-registration actually happened, so a failed resolve/parse retries next /reload instead of silently absorbing the edit forever; a victim the parse cannot restore is erased (treated as deleted) so a reappearing folder re-seeds. - Rescan: RefreshChangedMetadata returns whether anything was actually destroyed, not whether victims were collected — no display-array rebuild when every victim failed to resolve. - Rescan: comment the load-order consequence of evict + re-register (the fresh entry appends at the list tail; declared deps still force-load first).
475 lines
20 KiB
C++
475 lines
20 KiB
C++
// This file is part of ClassicAPI.
|
|
//
|
|
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
|
// of the GNU General Public License as published by the Free Software Foundation, either
|
|
// version 3 of the License, or (at your option) any later version.
|
|
//
|
|
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
|
// PURPOSE. See the GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License along with
|
|
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
// Embedded `!!!ClassicAPI` addon fallback. When the user doesn't have
|
|
// the addon installed on disk, this module makes the engine think it
|
|
// does — without writing anything to the filesystem.
|
|
//
|
|
// How:
|
|
//
|
|
// 1. The CMake build embeds every file under `AddOns/!!!ClassicAPI/`
|
|
// into a generated header (`embedded_classicapi.h`) as a byte
|
|
// array per file plus a `{path, data, size}` manifest.
|
|
//
|
|
// 2. We hook the engine's file-read function at `FUN_FILE_READ`.
|
|
// When the engine asks for a path under
|
|
// `Interface\AddOns\!!!ClassicAPI\`, we allocate a Storm buffer
|
|
// via the same allocator the engine itself uses, copy the
|
|
// embedded content in, and hand it back. The caller's normal
|
|
// `SMemFree` reclaims it on completion — no special lifetime.
|
|
//
|
|
// 3. We post-hook the engine's `FUN_ADDON_INIT`. After the engine's
|
|
// own `Interface\AddOns\` scan finishes, we call the TOC parser
|
|
// directly with `"!!!ClassicAPI"`. The parser opens its TOC via
|
|
// our hooked read function and registers the addon as a normal
|
|
// entry. The TOC parser is dedup-safe: if the user already has
|
|
// the addon on disk, the engine's scan picked it up, the
|
|
// hash-table lookup hits, and our call is a no-op. So the
|
|
// conditional "only if not on disk" behavior is free.
|
|
//
|
|
// Same pattern WeirdUtils.dll uses (we reverse-engineered it from
|
|
// `0x10015A74` while figuring this out). The addon load pass then
|
|
// reads each `.lua` file through the same hooked read path —
|
|
// `Compat.lua`, `Util\Print.lua`, etc. all surface from
|
|
// embedded buffers.
|
|
|
|
#include "embedded_classicapi.h"
|
|
|
|
#include "Game.h"
|
|
#include "Offsets.h"
|
|
#include "addons/EngineIO.h"
|
|
#include "addons/FlavorBindings.h"
|
|
#include "addons/FlavorToc.h"
|
|
#include "addons/Registry.h"
|
|
#include "addons/Toc.h"
|
|
#include "addons/TocRewrite.h"
|
|
#include "bindings/Inject.h"
|
|
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
|
|
namespace Addons::Embedded {
|
|
|
|
namespace {
|
|
|
|
constexpr const char *kAddonName = "!!!ClassicAPI";
|
|
constexpr const char *kAddonTocFile = "!!!ClassicAPI.toc";
|
|
|
|
// Developer marker. When this file exists in the on-disk addon folder,
|
|
// the disk copy wins unconditionally (see `DiskHasDevMarker` /
|
|
// `DecideSource`). It is gitignored, excluded from the embed, and the
|
|
// release ships only the DLL — so it can only exist where a developer
|
|
// deliberately created it, never in a normal user's install.
|
|
constexpr const char *kDevMarkerFile = ".classicapi-dev";
|
|
|
|
// `Interface\AddOns\!!!ClassicAPI\` — the prefix the engine
|
|
// constructs for any of our addon's files. Comparing case-
|
|
// insensitively because the TOC parser uses `Interface\AddOns\%s\…`
|
|
// where %s comes from the on-disk directory name, and Windows file
|
|
// paths are case-insensitive.
|
|
constexpr const char *kAddonPathPrefix = "Interface\\AddOns\\!!!ClassicAPI\\";
|
|
constexpr size_t kAddonPathPrefixLen = 30; // length of the literal above
|
|
|
|
char NormalizeChar(char c) {
|
|
if (c == '/') return '\\';
|
|
if (c >= 'A' && c <= 'Z') return static_cast<char>(c + 32);
|
|
return c;
|
|
}
|
|
|
|
bool PathEqualsCI(const char *a, const char *b) {
|
|
while (*a && *b) {
|
|
if (NormalizeChar(*a) != NormalizeChar(*b)) return false;
|
|
++a; ++b;
|
|
}
|
|
return *a == *b;
|
|
}
|
|
|
|
// Strip the `Interface\AddOns\!!!ClassicAPI\` prefix and return the
|
|
// suffix (e.g. `Util\Print.lua`) on a match, NULL otherwise.
|
|
const char *StripAddonPrefix(const char *path) {
|
|
if (path == nullptr) return nullptr;
|
|
const char *p = path;
|
|
const char *q = kAddonPathPrefix;
|
|
while (*q) {
|
|
if (*p == '\0') return nullptr;
|
|
if (NormalizeChar(*p) != NormalizeChar(*q)) return nullptr;
|
|
++p; ++q;
|
|
}
|
|
return p;
|
|
}
|
|
|
|
const ClassicAPIFiles::File *LookupEmbedded(const char *suffix) {
|
|
for (size_t i = 0; i < ClassicAPIFiles::kFileCount; ++i) {
|
|
if (PathEqualsCI(suffix, ClassicAPIFiles::kFiles[i].path))
|
|
return &ClassicAPIFiles::kFiles[i];
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
// Engine file I/O + Storm allocator — see addons/EngineIO.h for the shapes and
|
|
// the __stdcall / ESP-drift hazard. A buffer we SMemAlloc here is freed cleanly
|
|
// by the caller's standard SMemFree in turn.
|
|
using AddOns::EngineIO::FileReadFn;
|
|
using AddOns::EngineIO::SMemAllocFn;
|
|
using AddOns::EngineIO::SMemFreeFn;
|
|
|
|
// The trampoline to the original FUN_FILE_READ (installed by the co-hook below).
|
|
FileReadFn FileRead_o = nullptr;
|
|
|
|
// Extracts the value of the `## Version: X` line from a TOC byte
|
|
// buffer. Writes into `out` (size `outSize`) and returns true on
|
|
// success. The TOC format is `## Key: Value`, one per line, case-
|
|
// sensitive on the key. Returns false if no Version line is found.
|
|
bool ExtractTocVersion(const char *content, size_t size,
|
|
char *out, size_t outSize) {
|
|
const char *v = nullptr;
|
|
size_t n = 0;
|
|
if (outSize == 0)
|
|
return false;
|
|
if (!AddOns::Toc::FindValue(content, size, "## Version:", &v, &n) || n == 0) {
|
|
out[0] = '\0';
|
|
return false;
|
|
}
|
|
if (n >= outSize)
|
|
n = outSize - 1;
|
|
std::memcpy(out, v, n);
|
|
out[n] = '\0';
|
|
return true;
|
|
}
|
|
|
|
// Returns -1/0/+1 for `a < b` / `a == b` / `a > b`. "DEV" is the
|
|
// local-build sentinel: two DEV builds are equal, but a DEV build sorts
|
|
// BELOW any real release. Dev-over-release precedence is signalled
|
|
// explicitly by the `.classicapi-dev` marker now — NOT by the toc
|
|
// version — so a stray `## Version: DEV` disk copy (repo clone / old
|
|
// bundle) never silently shadows a newer embedded release. Otherwise
|
|
// the two strings are walked as dot-separated numeric semver components
|
|
// (`1.2` < `1.10`).
|
|
int CompareVersions(const char *a, const char *b) {
|
|
const bool aDev = std::strcmp(a, "DEV") == 0;
|
|
const bool bDev = std::strcmp(b, "DEV") == 0;
|
|
if (aDev && bDev) return 0;
|
|
if (aDev) return -1;
|
|
if (bDev) return 1;
|
|
while (*a || *b) {
|
|
int va = 0, vb = 0;
|
|
while (*a >= '0' && *a <= '9') { va = va * 10 + (*a - '0'); ++a; }
|
|
while (*b >= '0' && *b <= '9') { vb = vb * 10 + (*b - '0'); ++b; }
|
|
if (va != vb) return va < vb ? -1 : 1;
|
|
if (*a == '.') ++a;
|
|
if (*b == '.') ++b;
|
|
if (!*a && !*b) break;
|
|
if (!*a) return -1;
|
|
if (!*b) return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Pre-extracted embedded TOC version, populated lazily on first
|
|
// FileRead_h call under our prefix. Empty until populated.
|
|
char g_embeddedVersion[64] = "";
|
|
|
|
// Which source wins the embed-vs-disk comparison, decided once on
|
|
// first relevant file read.
|
|
enum class Source { Undecided, Disk, Embedded };
|
|
Source g_source = Source::Undecided;
|
|
|
|
void EnsureEmbeddedVersionExtracted() {
|
|
if (g_embeddedVersion[0] != '\0') return;
|
|
for (size_t i = 0; i < ClassicAPIFiles::kFileCount; ++i) {
|
|
if (PathEqualsCI(kAddonTocFile, ClassicAPIFiles::kFiles[i].path)) {
|
|
ExtractTocVersion(
|
|
reinterpret_cast<const char *>(ClassicAPIFiles::kFiles[i].data),
|
|
ClassicAPIFiles::kFiles[i].size,
|
|
g_embeddedVersion, sizeof(g_embeddedVersion));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// True iff the on-disk addon folder contains the `.classicapi-dev`
|
|
// marker. Read via the ORIGINAL `FUN_FILE_READ` (bypassing our embed
|
|
// hook), so it reflects a real file on disk / in an MPQ — never an
|
|
// embedded copy. That makes it a clean "is this a developer working
|
|
// tree" signal: the marker is gitignored + excluded from the embed and
|
|
// the release ships only the DLL, so a normal user's install can't
|
|
// carry it.
|
|
bool DiskHasDevMarker() {
|
|
char fullPath[256];
|
|
std::snprintf(fullPath, sizeof(fullPath), "%s%s",
|
|
kAddonPathPrefix, kDevMarkerFile);
|
|
void *buf = nullptr;
|
|
size_t size = 0;
|
|
const int ok = FileRead_o(0, fullPath, &buf, &size, 1, 1, 0);
|
|
if (ok == 0 || buf == nullptr)
|
|
return false;
|
|
auto SMemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
|
|
SMemFree(buf, __FILE__, __LINE__, 0);
|
|
return true;
|
|
}
|
|
|
|
// Decide whether disk or embedded should serve all reads for this
|
|
// addon. Called on the first FileRead_h call with a matching prefix.
|
|
// The `.classicapi-dev` marker forces disk unconditionally (explicit
|
|
// developer override). Otherwise reads the on-disk TOC via the original
|
|
// `FUN_FILE_READ` (bypassing our hook), parses its version, compares to
|
|
// the embedded version, and caches the newer. If disk has no TOC at
|
|
// all, embedded wins by default.
|
|
void DecideSource() {
|
|
if (g_source != Source::Undecided) return;
|
|
EnsureEmbeddedVersionExtracted();
|
|
|
|
// Explicit developer override: disk wins regardless of version. This
|
|
// is how a dev keeps disk precedence when running local Lua against a
|
|
// *released* DLL (where the embedded version would otherwise be newer
|
|
// and win). Dev intent lives in this marker, decoupled from the toc
|
|
// version — so users never get silently shadowed by a stray copy.
|
|
if (DiskHasDevMarker()) {
|
|
g_source = Source::Disk;
|
|
return;
|
|
}
|
|
|
|
char fullPath[256];
|
|
std::snprintf(fullPath, sizeof(fullPath), "%s%s",
|
|
kAddonPathPrefix, kAddonTocFile);
|
|
|
|
void *diskBuf = nullptr;
|
|
size_t diskSize = 0;
|
|
const int ok = FileRead_o(0, fullPath, &diskBuf, &diskSize, 1, 1, 0);
|
|
if (ok == 0 || diskBuf == nullptr) {
|
|
g_source = Source::Embedded;
|
|
return;
|
|
}
|
|
|
|
char diskVersion[64] = "";
|
|
ExtractTocVersion(static_cast<const char *>(diskBuf), diskSize,
|
|
diskVersion, sizeof(diskVersion));
|
|
|
|
// Free the disk buffer we just borrowed — we only needed it for
|
|
// the version-line scrape. The actual content for the engine's
|
|
// TOC read comes through the regular hook path below.
|
|
auto SMemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
|
|
SMemFree(diskBuf, __FILE__, __LINE__, 0);
|
|
|
|
// Missing or unparseable disk version → assume it's older than
|
|
// anything we ship, embedded wins.
|
|
if (diskVersion[0] == '\0') {
|
|
g_source = Source::Embedded;
|
|
return;
|
|
}
|
|
|
|
const int cmp = CompareVersions(g_embeddedVersion, diskVersion);
|
|
g_source = (cmp > 0) ? Source::Embedded : Source::Disk;
|
|
}
|
|
|
|
int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
|
|
size_t *outSize, size_t extraBytes,
|
|
int flag1, int flag2) {
|
|
// FrameXML Bindings.xml gets an inline splice for our TARGETING
|
|
// additions — see `bindings/Inject.cpp`. Returns true with the
|
|
// patched buffer ready for the engine's binding parser to consume.
|
|
if (Bindings::Inject::TryHandle(unused, path, outBuf, outSize,
|
|
extraBytes, flag1, flag2, FileRead_o)) {
|
|
return 1;
|
|
}
|
|
|
|
const char *suffix = StripAddonPrefix(path);
|
|
if (suffix == nullptr) {
|
|
// Not our embedded addon. If this is a base-TOC read for a multi-flavor
|
|
// addon that ships only `<Name>_Turtle.toc` / `_ClassicAPI.toc`, serve
|
|
// that in place of the missing `<Name>.toc` so it registers and loads
|
|
// (see addons/FlavorToc.h).
|
|
int result;
|
|
if (AddOns::FlavorToc::TryHandle(unused, path, outBuf, outSize,
|
|
extraBytes, flag1, flag2, FileRead_o)) {
|
|
result = 1;
|
|
} else if (AddOns::FlavorBindings::TryHandle(unused, path, outBuf, outSize,
|
|
extraBytes, flag1, flag2,
|
|
FileRead_o)) {
|
|
// Flavor-specific addon keybindings: serve `Bindings_Turtle.xml` /
|
|
// `Bindings_ClassicAPI.xml` for a `…\AddOns\<Name>\Bindings.xml`
|
|
// read (see addons/FlavorBindings.h). The paired exists-check
|
|
// co-hook lives in that module.
|
|
result = 1;
|
|
} else {
|
|
result = FileRead_o(unused, path, outBuf, outSize, extraBytes,
|
|
flag1, flag2);
|
|
}
|
|
// Per-line TOC directives: evaluate `[Condition]` and expand
|
|
// `[Variable]` tokens on this addon TOC's file lines so the load
|
|
// pass sees clean paths (see addons/TocRewrite.h). No-op unless
|
|
// `path` is an addon `.toc` that actually contains directives.
|
|
if (result != 0)
|
|
AddOns::TocRewrite::Transform(path, outBuf, outSize);
|
|
return result;
|
|
}
|
|
|
|
DecideSource();
|
|
|
|
if (g_source == Source::Disk) {
|
|
// Disk version is at least as new — serve disk. If disk
|
|
// doesn't have this specific file (e.g. embedded has files
|
|
// disk doesn't), fall through and try embedded.
|
|
const int diskResult = FileRead_o(unused, path, outBuf, outSize,
|
|
extraBytes, flag1, flag2);
|
|
if (diskResult != 0) return diskResult;
|
|
}
|
|
|
|
// Source::Embedded path, OR Source::Disk-but-file-missing fallback.
|
|
// Look up the embedded version and synthesize a Storm buffer the
|
|
// caller can free normally.
|
|
const auto *entry = LookupEmbedded(suffix);
|
|
if (entry == nullptr) {
|
|
// Not in our embedded set either — let the engine try disk
|
|
// one more time (returns 0 if that also fails). Covers
|
|
// pathological cases like an empty disk install + embedded
|
|
// missing some file.
|
|
return FileRead_o(unused, path, outBuf, outSize, extraBytes,
|
|
flag1, flag2);
|
|
}
|
|
|
|
auto SMemAlloc = reinterpret_cast<SMemAllocFn>(
|
|
Offsets::FUN_STORM_SMEM_ALLOC);
|
|
const size_t totalSize = entry->size + extraBytes;
|
|
void *buf = SMemAlloc(totalSize, __FILE__, __LINE__, 0);
|
|
if (buf == nullptr) return 0;
|
|
std::memcpy(buf, entry->data, entry->size);
|
|
if (extraBytes > 0) {
|
|
std::memset(static_cast<uint8_t *>(buf) + entry->size, 0, extraBytes);
|
|
}
|
|
if (outBuf != nullptr) *outBuf = buf;
|
|
if (outSize != nullptr) *outSize = entry->size;
|
|
return 1;
|
|
}
|
|
|
|
// `FUN_ADDON_INIT` — `__fastcall(accountName)`. We post-hook it: let
|
|
// the engine's normal `Interface\AddOns\` scan run first, then call
|
|
// the TOC parser with `"!!!ClassicAPI"`. Dedup-safe — see file header.
|
|
using AddonInit_t = void(__fastcall *)(const char *accountName);
|
|
AddonInit_t AddonInit_o = nullptr;
|
|
|
|
using TocParser_t = void(__fastcall *)(const char *name);
|
|
using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
|
|
int position, int anchor);
|
|
|
|
// `FUN_TOC_PARSER` appends new entries to the *tail* of the addon
|
|
// registry's linked list, and the engine's addon-load pass at
|
|
// `FUN_0051F600` walks that list in *insertion order* (NOT
|
|
// alphabetical order — the alphabetical sort only applies to the
|
|
// flat array `GetAddOnInfo(i)` reads from). So our post-hook
|
|
// injection lands at the very end of the load order, after every
|
|
// disk-scanned addon. Addons that consume our globals from their
|
|
// main chunk (idTip's `FrameWatcher`, etc.) would see `nil` and
|
|
// hard-error during their boot.
|
|
//
|
|
// Fix: walk the linked list, find our entry by name, and re-link
|
|
// it to the head via `FUN_INTRUSIVE_LIST_INSERT(position=1)`. The
|
|
// engine's own insert helper handles remove-then-insert atomically,
|
|
// so it's safe to call on an entry that's already in the list.
|
|
//
|
|
// The list is walked via the shared `Addons::ForEachEntry`
|
|
// (`addons/Registry.h`); the callback stops the walk once it finds and
|
|
// re-links our entry, since the re-link invalidates the current
|
|
// next-pointer.
|
|
//
|
|
// `entry + OFF_ADDON_ENTRY_FILTER_OUT` is the "filter-out" byte. The
|
|
// flat display-array builder `FUN_0051da70` walks the linked list and
|
|
// copies an entry into `GetNumAddOns`/`GetAddOnInfo`'s array ONLY when
|
|
// this byte is 0, so setting it hides `!!!ClassicAPI` from the
|
|
// character-select AddOns list — there's no checkbox, so it can't be
|
|
// toggled off. The builder only ever *writes* this byte on the
|
|
// secure/SMSG path (`entry+0x28 != 0`); our entry is non-secure, so a
|
|
// manual `1` here is stable across rebuilds. Hiding does NOT stop
|
|
// loading (the load pass `FUN_0051f600` never reads `+0x29`) and does
|
|
// NOT break dependency resolution or by-name queries (those use the
|
|
// addon hash table, which keeps the entry regardless).
|
|
|
|
// `entry+0x2b` — DefaultState (`## DefaultState: enabled/disabled`).
|
|
// The enable resolver `FUN_ADDON_ENABLE_RESOLVE` falls back to this
|
|
// when the player has no per-character override. Belt-and-suspenders
|
|
// alongside the resolver co-hook below (which forces enabled anyway).
|
|
constexpr int OFF_ADDON_ENTRY_DEFAULT_STATE = 0x2b;
|
|
|
|
// Post-registration finalize for the embedded entry: move it to the
|
|
// head of the load-order list (so consumers that read our globals from
|
|
// their main chunk see them — see the block comment above the offsets),
|
|
// hide it from the addon list, and mark it default-enabled. Combined
|
|
// with the `FUN_ADDON_ENABLE_RESOLVE` co-hook, `!!!ClassicAPI` always
|
|
// loads and never appears as a toggleable entry.
|
|
void FinalizeEmbeddedEntry() {
|
|
Addons::ForEachEntry([](uintptr_t entry) -> bool {
|
|
const char *name = *reinterpret_cast<const char *const *>(
|
|
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
|
|
if (name == nullptr || std::strcmp(name, kAddonName) != 0)
|
|
return true; // keep walking
|
|
|
|
// Hide from the character-select AddOns list and default it to
|
|
// enabled, then move it to the head of the load order.
|
|
*reinterpret_cast<uint8_t *>(
|
|
entry + Offsets::OFF_ADDON_ENTRY_FILTER_OUT) = 1;
|
|
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_DEFAULT_STATE) = 1;
|
|
|
|
auto fn = reinterpret_cast<ListInsert_t>(
|
|
Offsets::FUN_INTRUSIVE_LIST_INSERT);
|
|
fn(reinterpret_cast<void *>(
|
|
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL)),
|
|
reinterpret_cast<void *>(entry),
|
|
/*position=*/ 1, /*anchor=*/ 0);
|
|
return false; // stop — the re-link invalidated our next-pointer
|
|
});
|
|
}
|
|
|
|
void __fastcall AddonInit_h(const char *accountName) {
|
|
AddonInit_o(accountName);
|
|
auto TocParser = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
|
|
TocParser(kAddonName);
|
|
FinalizeEmbeddedEntry();
|
|
}
|
|
|
|
// `FUN_ADDON_ENABLE_RESOLVE` — per-character enable resolver. We
|
|
// co-hook it to force-enable `!!!ClassicAPI` regardless of DefaultState
|
|
// or any stale WTF disable-override, so hiding it from the addon list
|
|
// can never leave it stuck off. Returns 2 ("enabled for all"); the
|
|
// load-pass gate only tests `!= 0`. Every other addon falls through to
|
|
// the original untouched. See the Offsets.h note for the ABI.
|
|
using AddonEnableResolve_t =
|
|
uint32_t(__fastcall *)(const char *name, const char *account, char resolveAll);
|
|
AddonEnableResolve_t AddonEnableResolve_o = nullptr;
|
|
|
|
uint32_t __fastcall AddonEnableResolve_h(const char *name, const char *account,
|
|
char resolveAll) {
|
|
if (name != nullptr && PathEqualsCI(name, kAddonName))
|
|
return 2;
|
|
return AddonEnableResolve_o(name, account, resolveAll);
|
|
}
|
|
|
|
const Game::HookAutoRegister _hookFileRead{
|
|
Offsets::FUN_FILE_READ,
|
|
reinterpret_cast<void *>(&FileRead_h),
|
|
reinterpret_cast<void **>(&FileRead_o)};
|
|
|
|
const Game::HookAutoRegister _hookAddonInit{
|
|
Offsets::FUN_ADDON_INIT,
|
|
reinterpret_cast<void *>(&AddonInit_h),
|
|
reinterpret_cast<void **>(&AddonInit_o)};
|
|
|
|
const Game::HookAutoRegister _hookEnableResolve{
|
|
Offsets::FUN_ADDON_ENABLE_RESOLVE,
|
|
reinterpret_cast<void *>(&AddonEnableResolve_h),
|
|
reinterpret_cast<void **>(&AddonEnableResolve_o)};
|
|
|
|
} // namespace
|
|
|
|
} // namespace Addons::Embedded
|