// 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 . // 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 #include #include 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(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(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(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(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(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 `_Turtle.toc` / `_ClassicAPI.toc`, serve // that in place of the missing `.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\\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( 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(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( 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( entry + Offsets::OFF_ADDON_ENTRY_FILTER_OUT) = 1; *reinterpret_cast(entry + OFF_ADDON_ENTRY_DEFAULT_STATE) = 1; auto fn = reinterpret_cast( Offsets::FUN_INTRUSIVE_LIST_INSERT); fn(reinterpret_cast( static_cast(Offsets::VAR_ADDON_LIST_CTRL)), reinterpret_cast(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(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(&FileRead_h), reinterpret_cast(&FileRead_o)}; const Game::HookAutoRegister _hookAddonInit{ Offsets::FUN_ADDON_INIT, reinterpret_cast(&AddonInit_h), reinterpret_cast(&AddonInit_o)}; const Game::HookAutoRegister _hookEnableResolve{ Offsets::FUN_ADDON_ENABLE_RESOLVE, reinterpret_cast(&AddonEnableResolve_h), reinterpret_cast(&AddonEnableResolve_o)}; } // namespace } // namespace Addons::Embedded