Files
ClassicAPI/src/addons/Embedded.cpp
T
brues-code 6aea4a1752 Retail-like /reload: new addons, new files, ## metadata edits, deletions (#37)
* 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).
2026-08-29 19:16:34 -05:00

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