Metin2 game client (P0–P11) + mobile asset pipeline
Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
phases, EntityStore world model, ~all GC/CG headers. char create/delete,
private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
token), system-option + game-option + ESC system menu, private-shop 39-grid,
party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.
Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.
Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).
ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
#include "asset_source.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace mtpack {
|
||||
|
||||
bool AssetSource::build(const std::string &assets_root, const std::string &pack_dir,
|
||||
const std::string &cache_dir, std::string *err) {
|
||||
if (!m_loose.build(assets_root, fmt::AssetResolver::default_priority(), err)) {
|
||||
return false;
|
||||
}
|
||||
if (!pack_dir.empty()) {
|
||||
int n = m_packs.scan_dir(pack_dir);
|
||||
m_have_packs = n > 0;
|
||||
m_cache = !cache_dir.empty()
|
||||
? cache_dir
|
||||
: (fs::path(pack_dir) / ".mtcache").string();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AssetSource::exists(const std::string &vpath) const {
|
||||
if (!m_loose.resolve(vpath).empty()) {
|
||||
return true;
|
||||
}
|
||||
return m_have_packs && m_packs.has(vpath);
|
||||
}
|
||||
|
||||
bool AssetSource::read(const std::string &vpath, std::vector<uint8_t> &out, std::string *err) const {
|
||||
std::string lp = m_loose.resolve(vpath);
|
||||
if (!lp.empty()) {
|
||||
std::ifstream f(lp, std::ios::binary);
|
||||
if (f) {
|
||||
out.assign((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (m_have_packs && m_packs.read(vpath, out, err)) {
|
||||
return true;
|
||||
}
|
||||
if (err && err->empty()) {
|
||||
*err = "not found: " + vpath;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string AssetSource::to_path(const std::string &vpath, std::string *err) const {
|
||||
std::string lp = m_loose.resolve(vpath);
|
||||
if (!lp.empty()) {
|
||||
return lp;
|
||||
}
|
||||
if (!m_have_packs || !m_packs.has(vpath)) {
|
||||
if (err) *err = "not found: " + vpath;
|
||||
return "";
|
||||
}
|
||||
// extract to cache/<normalized vpath>, once
|
||||
std::string rel = PackMount::norm(vpath);
|
||||
fs::path dst = fs::path(m_cache) / rel;
|
||||
std::error_code ec;
|
||||
if (fs::exists(dst, ec) && fs::file_size(dst, ec) > 0) {
|
||||
return dst.string();
|
||||
}
|
||||
std::vector<uint8_t> bytes;
|
||||
if (!m_packs.read(vpath, bytes, err)) {
|
||||
return "";
|
||||
}
|
||||
fs::create_directories(dst.parent_path(), ec);
|
||||
std::ofstream o(dst, std::ios::binary | std::ios::trunc);
|
||||
if (!o) {
|
||||
if (err) *err = "cannot write cache file " + dst.string();
|
||||
return "";
|
||||
}
|
||||
o.write(reinterpret_cast<const char *>(bytes.data()),
|
||||
static_cast<std::streamsize>(bytes.size()));
|
||||
o.close();
|
||||
return dst.string();
|
||||
}
|
||||
|
||||
} // namespace mtpack
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
// AssetSource —— unified asset lookup over loose files + .epk packs.
|
||||
//
|
||||
// src.build(assets_root, pack_dir);
|
||||
// auto p = src.to_path("d:/ymir work/ui/pattern/board_base.tga");
|
||||
// // p is a real filesystem path: the loose file if present, else the pack
|
||||
// // entry extracted once into <cache>/... . Callers that expect a path keep
|
||||
// // working; new code can use read() for bytes directly.
|
||||
//
|
||||
// Precedence (dev-friendly, matches the client's pack/ overlay): a loose file
|
||||
// wins over a packed one. Within packs, later-mounted (patch) wins.
|
||||
|
||||
#include "asset_resolver.h" // xrender::formats
|
||||
#include "pack_mount.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace mtpack {
|
||||
|
||||
class AssetSource {
|
||||
public:
|
||||
// `pack_dir` may be empty (loose-only). `cache_dir` is where packed files
|
||||
// get extracted for to_path(); defaults to <pack_dir>/.mtcache or a temp dir.
|
||||
bool build(const std::string &assets_root, const std::string &pack_dir = "",
|
||||
const std::string &cache_dir = "", std::string *err = nullptr);
|
||||
|
||||
bool exists(const std::string &vpath) const;
|
||||
|
||||
// Real path for `vpath`: loose file, or a pack entry extracted to the cache.
|
||||
// Empty string if not found anywhere.
|
||||
std::string to_path(const std::string &vpath, std::string *err = nullptr) const;
|
||||
|
||||
// Bytes for `vpath` (loose read or pack decompress). false if not found.
|
||||
bool read(const std::string &vpath, std::vector<uint8_t> &out, std::string *err = nullptr) const;
|
||||
|
||||
const fmt::AssetResolver &loose() const { return m_loose; }
|
||||
const PackMount &packs() const { return m_packs; }
|
||||
|
||||
private:
|
||||
fmt::AssetResolver m_loose;
|
||||
PackMount m_packs;
|
||||
std::string m_cache;
|
||||
bool m_have_packs = false;
|
||||
};
|
||||
|
||||
} // namespace mtpack
|
||||
@@ -0,0 +1,266 @@
|
||||
#include "eterpack.h"
|
||||
|
||||
#include <sodium.h>
|
||||
#include <zstd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
|
||||
namespace mtpack {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr size_t HEADER_SIZE = 8 + 8 + PACK_NONCE_SIZE; // 40
|
||||
// entry tail after the name field: offset,file_size,compressed_size,encryption,nonce
|
||||
constexpr size_t ENTRY_TAIL = 8 + 8 + 8 + 1 + PACK_NONCE_SIZE; // 49
|
||||
|
||||
void xchacha20(uint8_t *data, size_t len, const uint8_t *nonce) {
|
||||
crypto_stream_xchacha20_xor(data, data, len, nonce, PACK_KEY);
|
||||
}
|
||||
|
||||
// Parse one decrypted entry blob of `field + ENTRY_TAIL` bytes.
|
||||
Entry parse_entry(const uint8_t *p, int field) {
|
||||
Entry e;
|
||||
size_t nlen = strnlen(reinterpret_cast<const char *>(p), field);
|
||||
e.name.assign(reinterpret_cast<const char *>(p), nlen);
|
||||
const uint8_t *q = p + field;
|
||||
std::memcpy(&e.offset, q, 8);
|
||||
q += 8;
|
||||
std::memcpy(&e.file_size, q, 8);
|
||||
q += 8;
|
||||
std::memcpy(&e.compressed_size, q, 8);
|
||||
q += 8;
|
||||
e.encryption = *q++;
|
||||
std::memcpy(e.nonce, q, PACK_NONCE_SIZE);
|
||||
return e;
|
||||
}
|
||||
|
||||
bool plausible(const Entry &e, uint64_t data_begin, uint64_t file_total) {
|
||||
if (e.encryption > 1) {
|
||||
return false;
|
||||
}
|
||||
// empty files are legal; a zstd frame is never 0 bytes though
|
||||
if (e.compressed_size == 0 || e.compressed_size > file_total) {
|
||||
return false;
|
||||
}
|
||||
if (data_begin + e.offset + e.compressed_size > file_total) {
|
||||
return false;
|
||||
}
|
||||
if (e.name.empty()) {
|
||||
return false;
|
||||
}
|
||||
// filenames may be CP949 (Korean) — reject only ASCII control bytes.
|
||||
for (unsigned char c : e.name) {
|
||||
if (c < 0x20) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string EterPack::norm(std::string s) {
|
||||
std::replace(s.begin(), s.end(), '\\', '/');
|
||||
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); });
|
||||
return s;
|
||||
}
|
||||
|
||||
void EterPack::close() {
|
||||
m_file.clear();
|
||||
m_index.clear();
|
||||
m_by_name.clear();
|
||||
m_data_begin = 0;
|
||||
}
|
||||
|
||||
bool EterPack::open(const std::string &path, std::string *err) {
|
||||
close();
|
||||
if (sodium_init() < 0) {
|
||||
if (err) *err = "sodium_init failed";
|
||||
return false;
|
||||
}
|
||||
std::ifstream f(path, std::ios::binary);
|
||||
if (!f) {
|
||||
if (err) *err = "cannot open " + path;
|
||||
return false;
|
||||
}
|
||||
f.seekg(0, std::ios::end);
|
||||
std::streamoff sz = f.tellg();
|
||||
f.seekg(0);
|
||||
if (sz < static_cast<std::streamoff>(HEADER_SIZE)) {
|
||||
if (err) *err = "file too small";
|
||||
return false;
|
||||
}
|
||||
m_file.resize(static_cast<size_t>(sz));
|
||||
f.read(reinterpret_cast<char *>(m_file.data()), sz);
|
||||
|
||||
uint64_t entry_num = 0;
|
||||
std::memcpy(&entry_num, m_file.data(), 8);
|
||||
std::memcpy(&m_data_begin, m_file.data() + 8, 8);
|
||||
const uint8_t *hnonce = m_file.data() + 16;
|
||||
|
||||
if (entry_num == 0 || m_data_begin < HEADER_SIZE || m_data_begin > m_file.size()) {
|
||||
if (err) *err = "bad header";
|
||||
return false;
|
||||
}
|
||||
const uint64_t index_bytes = m_data_begin - HEADER_SIZE;
|
||||
|
||||
// Candidate name-field sizes: derived first, then the known platform values.
|
||||
std::vector<int> candidates;
|
||||
if (index_bytes % entry_num == 0) {
|
||||
int64_t es = static_cast<int64_t>(index_bytes / entry_num);
|
||||
if (es > static_cast<int64_t>(ENTRY_TAIL) + 1) {
|
||||
candidates.push_back(static_cast<int>(es - ENTRY_TAIL));
|
||||
}
|
||||
}
|
||||
for (int v : {261, 4097, 1025, 256}) {
|
||||
candidates.push_back(v);
|
||||
}
|
||||
|
||||
for (int field : candidates) {
|
||||
const size_t entry_size = static_cast<size_t>(field) + ENTRY_TAIL;
|
||||
if (HEADER_SIZE + entry_num * entry_size > m_file.size()) {
|
||||
continue;
|
||||
}
|
||||
std::vector<Entry> idx;
|
||||
idx.reserve(entry_num);
|
||||
bool ok = true;
|
||||
std::vector<uint8_t> blob(entry_size);
|
||||
for (uint64_t i = 0; i < entry_num && ok; ++i) {
|
||||
std::memcpy(blob.data(), m_file.data() + HEADER_SIZE + i * entry_size, entry_size);
|
||||
xchacha20(blob.data(), entry_size, hnonce);
|
||||
Entry e = parse_entry(blob.data(), field);
|
||||
if (!plausible(e, m_data_begin, m_file.size())) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
idx.push_back(std::move(e));
|
||||
}
|
||||
if (ok) {
|
||||
m_index = std::move(idx);
|
||||
m_name_field = field;
|
||||
for (size_t i = 0; i < m_index.size(); ++i) {
|
||||
m_by_name[norm(m_index[i].name)] = i;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (err) *err = "could not resolve index layout (name-field guess failed)";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<std::string> EterPack::names() const {
|
||||
std::vector<std::string> v;
|
||||
v.reserve(m_index.size());
|
||||
for (const auto &e : m_index) {
|
||||
v.push_back(e.name);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
bool EterPack::read(const std::string &name, std::vector<uint8_t> &out, std::string *err) const {
|
||||
auto it = m_by_name.find(norm(name));
|
||||
if (it == m_by_name.end()) {
|
||||
if (err) *err = "not in pack: " + name;
|
||||
return false;
|
||||
}
|
||||
const Entry &e = m_index[it->second];
|
||||
const uint8_t *src = m_file.data() + m_data_begin + e.offset;
|
||||
|
||||
std::vector<uint8_t> comp(e.compressed_size);
|
||||
std::memcpy(comp.data(), src, e.compressed_size);
|
||||
if (e.encryption == 1) {
|
||||
crypto_stream_xchacha20_xor(comp.data(), comp.data(), comp.size(), e.nonce, PACK_KEY);
|
||||
}
|
||||
|
||||
out.resize(e.file_size);
|
||||
size_t n = ZSTD_decompress(out.data(), out.size(), comp.data(), comp.size());
|
||||
if (ZSTD_isError(n) || n != e.file_size) {
|
||||
if (err) *err = std::string("zstd: ") + (ZSTD_isError(n) ? ZSTD_getErrorName(n) : "size mismatch");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- writer ----------------------------------------------------------------
|
||||
|
||||
bool write_pack(const std::string &out_path, const std::vector<InputFile> &files, bool encrypt,
|
||||
std::string *err) {
|
||||
if (sodium_init() < 0) {
|
||||
if (err) *err = "sodium_init failed";
|
||||
return false;
|
||||
}
|
||||
const int field = PACK_NAME_FIELD_DEFAULT;
|
||||
const size_t entry_size = static_cast<size_t>(field) + ENTRY_TAIL;
|
||||
const uint64_t entry_num = files.size();
|
||||
const uint64_t data_begin = HEADER_SIZE + entry_num * entry_size;
|
||||
|
||||
uint8_t header_nonce[PACK_NONCE_SIZE];
|
||||
randombytes_buf(header_nonce, sizeof(header_nonce));
|
||||
|
||||
std::vector<uint8_t> index(entry_num * entry_size, 0);
|
||||
std::vector<uint8_t> data;
|
||||
uint64_t cursor = 0;
|
||||
|
||||
for (uint64_t i = 0; i < entry_num; ++i) {
|
||||
const InputFile &in = files[i];
|
||||
size_t bound = ZSTD_compressBound(in.data.size());
|
||||
std::vector<uint8_t> comp(bound);
|
||||
size_t clen = ZSTD_compress(comp.data(), comp.size(), in.data.data(), in.data.size(), 3);
|
||||
if (ZSTD_isError(clen)) {
|
||||
if (err) *err = std::string("zstd compress: ") + ZSTD_getErrorName(clen);
|
||||
return false;
|
||||
}
|
||||
comp.resize(clen);
|
||||
|
||||
Entry e;
|
||||
e.name = in.name;
|
||||
e.offset = cursor;
|
||||
e.file_size = in.data.size();
|
||||
e.compressed_size = clen;
|
||||
e.encryption = encrypt ? 1 : 0;
|
||||
if (encrypt) {
|
||||
randombytes_buf(e.nonce, sizeof(e.nonce));
|
||||
crypto_stream_xchacha20_xor(comp.data(), comp.data(), comp.size(), e.nonce, PACK_KEY);
|
||||
}
|
||||
|
||||
// serialize entry (plaintext), then encrypt the whole index blob at the end
|
||||
uint8_t *p = index.data() + i * entry_size;
|
||||
std::string nm = e.name;
|
||||
std::replace(nm.begin(), nm.end(), '\\', '/');
|
||||
std::memcpy(p, nm.data(), std::min<size_t>(nm.size(), field - 1));
|
||||
uint8_t *q = p + field;
|
||||
std::memcpy(q, &e.offset, 8);
|
||||
q += 8;
|
||||
std::memcpy(q, &e.file_size, 8);
|
||||
q += 8;
|
||||
std::memcpy(q, &e.compressed_size, 8);
|
||||
q += 8;
|
||||
*q++ = e.encryption;
|
||||
std::memcpy(q, e.nonce, PACK_NONCE_SIZE);
|
||||
|
||||
data.insert(data.end(), comp.begin(), comp.end());
|
||||
cursor += clen;
|
||||
}
|
||||
|
||||
// encrypt the index with the header nonce
|
||||
for (uint64_t i = 0; i < entry_num; ++i) {
|
||||
crypto_stream_xchacha20_xor(index.data() + i * entry_size, index.data() + i * entry_size,
|
||||
entry_size, header_nonce, PACK_KEY);
|
||||
}
|
||||
|
||||
std::ofstream f(out_path, std::ios::binary | std::ios::trunc);
|
||||
if (!f) {
|
||||
if (err) *err = "cannot write " + out_path;
|
||||
return false;
|
||||
}
|
||||
f.write(reinterpret_cast<const char *>(&entry_num), 8);
|
||||
f.write(reinterpret_cast<const char *>(&data_begin), 8);
|
||||
f.write(reinterpret_cast<const char *>(header_nonce), PACK_NONCE_SIZE);
|
||||
f.write(reinterpret_cast<const char *>(index.data()), static_cast<std::streamsize>(index.size()));
|
||||
f.write(reinterpret_cast<const char *>(data.data()), static_cast<std::streamsize>(data.size()));
|
||||
return static_cast<bool>(f);
|
||||
}
|
||||
|
||||
} // namespace mtpack
|
||||
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
// EterPack — reader/writer for this m2dev fork's single-file asset pack
|
||||
// (PackLib/Pack.cpp). NOT the classic Metin2 .eix/.epk format.
|
||||
//
|
||||
// Layout:
|
||||
// [Header: u64 entry_num, u64 data_begin, u8 nonce[24]] (40 bytes)
|
||||
// [Entry x entry_num] each XChaCha20-encrypted with Header.nonce + PACK_KEY
|
||||
// Entry: char name[NAME_FIELD], u64 offset, u64 file_size,
|
||||
// u64 compressed_size, u8 encryption, u8 nonce[24]
|
||||
// [data blob @ data_begin]
|
||||
// per file @ data_begin+offset, compressed_size bytes:
|
||||
// encryption==0 -> zstd frame -> zstd-decompress to file_size
|
||||
// encryption==1 -> XChaCha20(entry.nonce)-> zstd-decompress to file_size
|
||||
//
|
||||
// NAME_FIELD is FILENAME_MAX+1 on the packer's platform (MSVC PackMaker = 261).
|
||||
// The reader derives it from (data_begin - 40) / entry_num, falling back to the
|
||||
// known platform values, so it stays compatible with real packs.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace mtpack {
|
||||
|
||||
inline constexpr int PACK_NONCE_SIZE = 24;
|
||||
inline constexpr int PACK_KEY_SIZE = 32;
|
||||
|
||||
// PackLib/config.h PACK_KEY — the fork ships this hardcoded.
|
||||
inline constexpr uint8_t PACK_KEY[PACK_KEY_SIZE] = {
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
|
||||
0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
|
||||
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
|
||||
0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10
|
||||
};
|
||||
|
||||
// MSVC PackMaker.exe: FILENAME_MAX == 260, so the name field is 261 bytes.
|
||||
inline constexpr int PACK_NAME_FIELD_DEFAULT = 261;
|
||||
|
||||
struct Entry {
|
||||
std::string name;
|
||||
uint64_t offset = 0;
|
||||
uint64_t file_size = 0;
|
||||
uint64_t compressed_size = 0;
|
||||
uint8_t encryption = 0;
|
||||
uint8_t nonce[PACK_NONCE_SIZE] = {};
|
||||
};
|
||||
|
||||
class EterPack {
|
||||
public:
|
||||
// Load the index (mmaps the file; keeps it open for read()).
|
||||
bool open(const std::string &path, std::string *err = nullptr);
|
||||
void close();
|
||||
|
||||
bool has(const std::string &name) const { return m_by_name.count(norm(name)) != 0; }
|
||||
std::vector<std::string> names() const;
|
||||
size_t count() const { return m_index.size(); }
|
||||
|
||||
// Decompress (+decrypt) one file into `out`.
|
||||
bool read(const std::string &name, std::vector<uint8_t> &out, std::string *err = nullptr) const;
|
||||
|
||||
int name_field() const { return m_name_field; }
|
||||
|
||||
private:
|
||||
static std::string norm(std::string s); // '\\'->'/', lowercase
|
||||
|
||||
std::vector<uint8_t> m_file; // whole pack in memory (simple; mmap later)
|
||||
uint64_t m_data_begin = 0;
|
||||
std::vector<Entry> m_index;
|
||||
std::unordered_map<std::string, size_t> m_by_name;
|
||||
int m_name_field = PACK_NAME_FIELD_DEFAULT;
|
||||
};
|
||||
|
||||
// Build a pack from (name, bytes) pairs. `encrypt` -> per-file encryption==1.
|
||||
// Uses PACK_NAME_FIELD_DEFAULT so real clients / PackLib can read it back.
|
||||
struct InputFile {
|
||||
std::string name;
|
||||
std::vector<uint8_t> data;
|
||||
};
|
||||
bool write_pack(const std::string &out_path, const std::vector<InputFile> &files, bool encrypt,
|
||||
std::string *err = nullptr);
|
||||
|
||||
} // namespace mtpack
|
||||
@@ -0,0 +1,113 @@
|
||||
#include "pack_mount.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace mtpack {
|
||||
|
||||
std::string PackMount::norm(std::string s) {
|
||||
std::replace(s.begin(), s.end(), '\\', '/');
|
||||
// strip a leading "x:/" drive
|
||||
if (s.size() > 2 && s[1] == ':') {
|
||||
s = s.substr(2);
|
||||
}
|
||||
while (!s.empty() && s.front() == '/') {
|
||||
s.erase(s.begin());
|
||||
}
|
||||
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); });
|
||||
// fold "//"
|
||||
std::string o;
|
||||
o.reserve(s.size());
|
||||
for (char c : s) {
|
||||
if (c == '/' && !o.empty() && o.back() == '/') {
|
||||
continue;
|
||||
}
|
||||
o.push_back(c);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
bool PackMount::mount(const std::string &epk_path, std::string *err) {
|
||||
auto pk = std::make_shared<EterPack>();
|
||||
if (!pk->open(epk_path, err)) {
|
||||
return false;
|
||||
}
|
||||
const size_t pi = m_packs.size();
|
||||
m_packs.push_back(pk);
|
||||
for (const std::string &name : pk->names()) {
|
||||
std::string n = norm(name);
|
||||
m_by_norm[n] = {pi, name}; // later pack wins
|
||||
auto p = n.find("ymir work/");
|
||||
if (p != std::string::npos) {
|
||||
m_by_ymir[n.substr(p + 10)] = {pi, name};
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int PackMount::scan_dir(const std::string &dir, const std::string &patch_prefix) {
|
||||
std::error_code ec;
|
||||
if (!fs::is_directory(dir, ec)) {
|
||||
return 0;
|
||||
}
|
||||
std::vector<std::string> base, patch;
|
||||
for (const auto &e : fs::directory_iterator(dir, ec)) {
|
||||
if (!e.is_regular_file()) {
|
||||
continue;
|
||||
}
|
||||
const auto &p = e.path();
|
||||
if (p.extension() != ".epk") {
|
||||
continue;
|
||||
}
|
||||
if (!patch_prefix.empty() && p.filename().string().rfind(patch_prefix, 0) == 0) {
|
||||
patch.push_back(p.string());
|
||||
} else {
|
||||
base.push_back(p.string());
|
||||
}
|
||||
}
|
||||
std::sort(base.begin(), base.end());
|
||||
std::sort(patch.begin(), patch.end());
|
||||
|
||||
int n = 0;
|
||||
for (const auto &v : {&base, &patch}) {
|
||||
for (const std::string &f : *v) {
|
||||
if (mount(f)) {
|
||||
++n;
|
||||
}
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
bool PackMount::has(const std::string &vp) const {
|
||||
std::string n = norm(vp);
|
||||
if (m_by_norm.count(n)) {
|
||||
return true;
|
||||
}
|
||||
auto p = n.find("ymir work/");
|
||||
std::string suf = (p != std::string::npos) ? n.substr(p + 10) : n;
|
||||
return m_by_ymir.count(suf) != 0;
|
||||
}
|
||||
|
||||
bool PackMount::read(const std::string &vp, std::vector<uint8_t> &out, std::string *err) const {
|
||||
std::string n = norm(vp);
|
||||
const Ref *r = nullptr;
|
||||
if (auto it = m_by_norm.find(n); it != m_by_norm.end()) {
|
||||
r = &it->second;
|
||||
} else {
|
||||
auto p = n.find("ymir work/");
|
||||
std::string suf = (p != std::string::npos) ? n.substr(p + 10) : n;
|
||||
if (auto it2 = m_by_ymir.find(suf); it2 != m_by_ymir.end()) {
|
||||
r = &it2->second;
|
||||
}
|
||||
}
|
||||
if (!r) {
|
||||
if (err) *err = "not mounted: " + vp;
|
||||
return false;
|
||||
}
|
||||
return m_packs[r->pack]->read(r->name, out, err);
|
||||
}
|
||||
|
||||
} // namespace mtpack
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
// PackMount —— mount one or more .epk packs and resolve/read virtual paths
|
||||
// (`d:\ymir work\...`) out of them, mirroring fmt::AssetResolver's normalization
|
||||
// and "ymir work/"-suffix indexing so packed and loose assets share one lookup.
|
||||
//
|
||||
// Priority: packs mounted later win (call order = base packs first, patches
|
||||
// last), matching the loose-file resolver's pack_priority convention.
|
||||
|
||||
#include "eterpack.h"
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace mtpack {
|
||||
|
||||
class PackMount {
|
||||
public:
|
||||
// Open one pack and add its entries to the index. Returns false if it
|
||||
// won't open; missing files are not an error for scan_dir().
|
||||
bool mount(const std::string &epk_path, std::string *err = nullptr);
|
||||
|
||||
// Open every *.epk directly under `dir` (non-recursive), sorted by name so
|
||||
// mount order is deterministic. `patch_prefix` packs (name starts with it)
|
||||
// are mounted after the rest, so they override.
|
||||
int scan_dir(const std::string &dir, const std::string &patch_prefix = "metin2_patch_");
|
||||
|
||||
bool has(const std::string &virtual_path) const;
|
||||
// Decompress the file into `out`. Tries the full normalized path, then the
|
||||
// "ymir work/..." suffix.
|
||||
bool read(const std::string &virtual_path, std::vector<uint8_t> &out,
|
||||
std::string *err = nullptr) const;
|
||||
|
||||
size_t pack_count() const { return m_packs.size(); }
|
||||
size_t entry_count() const { return m_by_norm.size(); }
|
||||
|
||||
// path normalization shared with fmt::AssetResolver: '\'->'/', strip drive,
|
||||
// strip leading '/', lowercase, fold '//'.
|
||||
static std::string norm(std::string s);
|
||||
|
||||
private:
|
||||
struct Ref {
|
||||
size_t pack; // index into m_packs
|
||||
std::string name; // exact entry name in that pack
|
||||
};
|
||||
|
||||
std::vector<std::shared_ptr<EterPack>> m_packs;
|
||||
std::unordered_map<std::string, Ref> m_by_norm; // full normalized path
|
||||
std::unordered_map<std::string, Ref> m_by_ymir; // after "ymir work/"
|
||||
};
|
||||
|
||||
} // namespace mtpack
|
||||
Reference in New Issue
Block a user