sots-engine/docs/mars-vfs.md

216 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# `mars::vfs` — the layered virtual filesystem
`src/mars/vfs/` reimplements the engine's file layer: the `.gob` archives
(plain ZIP files) stacked under a loose-file override directory, searched in
order, with case-insensitive, separator-agnostic paths. Library target:
`mars_vfs` (static, `src/mars/vfs/CMakeLists.txt`). Include as
`mars/vfs/<header>.h`. C++17, no exceptions cross the API; DEFLATE and
CRC-32 come from the vendored [miniz 3.1.2](../third_party/miniz/) (MIT,
upstream release copied verbatim; built with the archive/stdio/zlib-name
surfaces compiled out).
The behaviour spec is the RE notes (`sots-re/findings/subsystems/data-model.md`
§1–2 and the VFS paragraph of `strings-and-config.md`): the engine's `gobio`
layer is a ZIP filesystem with a native-directory override, `sots.gob` and
`sots_local_en.gob` are registered and searched in order, and every entry
in both is stored uncompressed.
## API
```cpp
namespace mars::vfs {
// result.h
struct Error { enum class Code { NotFound, Io, BadArchive, Corrupt, Unsupported }; Code code; std::string message; };
template <class T> class Result; // value() or error(); explicit operator bool
// path.h
std::string normalize_key(std::string_view); // "Species\\Human\\X.txt" -> "species/human/x.txt"
std::string normalize_name(std::string_view); // same, original case kept
// zip_archive.h
struct ZipEntry { std::string name, key; uint64_t local_header_offset; uint32_t compressed_size,
uncompressed_size, crc32; uint16_t method, flags, dos_time, dos_date; bool is_directory; };
class ZipArchive {
static Result<ZipArchive> open(const std::string& path);
const std::vector<ZipEntry>& entries() const; size_t file_count() const; std::string comment() const;
const ZipEntry* find(std::string_view rel) const; // files only, any spelling
Result<std::vector<uint8_t>> read(const ZipEntry&) const;
Result<std::vector<uint8_t>> read(std::string_view rel) const;
};
// vfs.h
enum class MountKind { Native, Zip };
enum class Order { NativeFirst, Registration };
struct MountInfo { MountId id; MountKind kind; std::string path; size_t file_count; };
struct Stat { std::string name; uint64_t size; MountId mount; MountKind kind; bool compressed; };
struct ListEntry { std::string name; uint64_t size; MountId mount; };
class Vfs {
explicit Vfs(Order order = Order::NativeFirst);
Result<MountId> mount_zip(const std::string& archive);
Result<MountId> mount_native(const std::string& directory);
Result<size_t> rescan_native(MountId);
const std::vector<MountInfo>& mounts() const; std::vector<MountId> search_order() const;
const ZipArchive* archive(MountId) const;
bool exists(std::string_view rel) const;
std::optional<Stat> stat(std::string_view rel) const;
Result<std::vector<uint8_t>> read(std::string_view rel) const;
Result<std::string> read_text(std::string_view rel) const;
std::vector<ListEntry> list(std::string_view prefix = {}) const;
};
}
```
## Container format as implemented
A `.gob` is a ZIP file in the classic 32-bit layout (PKWARE APPNOTE 4.x):
```
[local file header][name][extra][data] one per entry, in file order
...
[central directory header][name][extra][comment] one per entry
...
[end of central directory record][comment] last thing in the file
```
`ZipArchive::open`:
1. Reads the file's tail (at most 22 + 65535 bytes) and scans backwards for
the EOCD signature `50 4B 05 06`. A candidate is accepted when its comment
length fits inside the tail; trailing junk after a comment-less record is
therefore tolerated, a record whose declared comment overruns the file is
not.
2. From the EOCD: disk numbers (must be 0 / single disk), entry count,
central-directory size and offset, comment. `0xFFFF` / `0xFFFFFFFF`
sentinel values mean ZIP64 → `Unsupported`. The directory must lie
before the EOCD → otherwise `BadArchive`.
3. Reads the whole central directory (≈600 KB for the 8,352-entry
`sots.gob`) and walks the 46-byte headers: flags, method, DOS time/date,
CRC-32, sizes, name, local-header offset. Any truncated or mis-signed
header → `BadArchive`; a per-entry ZIP64 sentinel → `Unsupported`.
4. Names ending in `/` (or `\`) are directory placeholders: kept in
`entries()`, excluded from `file_count()`, `find()` and `list()`.
Every entry gets a normalised key; if two entries fold to the same key the
first in the directory wins.
`ZipArchive::read(entry)` seeks to the entry's local header, checks its
signature `50 4B 03 04`, and locates the data using the *local* header's
name/extra lengths (they may differ from the central copy's — one unit test
covers that). Sizes and CRC come from the central directory, which is
authoritative even when bit 3 (data descriptor) is set. Method 0 is a single
positioned read of `uncompressed_size` bytes; method 8 reads
`compressed_size` bytes and inflates with `tinfl_decompress_mem_to_mem` (raw
DEFLATE, no zlib header) into an exactly-sized buffer. Every read verifies
the CRC-32 (`Corrupt` on mismatch), refuses encrypted entries and other
methods (`Unsupported`), and rejects a stored entry whose two sizes disagree
(`Corrupt`).
Not implemented, by design: ZIP64, multi-disk, encryption, methods other than
0/8, the extra-field time stamps (raw DOS time/date are exposed on the entry
untranslated).
## Override rules
`Vfs` holds a list of mounts. Lookups (`exists`, `stat`, `read`, `list`) walk
`search_order()` and take the first mount that has the key:
| `Order` | search order |
|----------------|----------------------------------------------------------------|
| `NativeFirst` | every native mount in registration order, then every zip mount in registration order — the rule the RE notes describe (a loose file on disk beats the archived copy; archives are consulted in the order registered) |
| `Registration` | strictly the order `mount_*` was called |
Exact load order in the original is still an open question in the notes, so
the policy is a constructor parameter rather than baked in. Under either
policy `stat().mount` and `ListEntry::mount` say which mount won.
A native mount is indexed once at mount time (recursive walk, regular files
only) so lookups are case-insensitive even on a case-sensitive host
filesystem. Two on-disk names that fold to one key resolve to the
lexicographically first spelling. `rescan_native(id)` re-walks the directory.
`..` segments are never resolved, so a lookup cannot escape the mount.
`list(prefix)` is a plain prefix match on normalised keys (`""` = everything,
`"Weapons/"` = a directory, `"Weapons/_"` = a name stem). Each key appears
once, attributed to the winning mount, with that mount's spelling of the
name, sorted by key.
### Path normalisation (`path.h`)
`/` and `\` both separate; empty and `.` segments and leading/trailing
separators are dropped; `A`–`Z` fold to lower case; every other byte
(including cp1252 high bytes) is untouched. So
`.\Species\HUMAN//sections/CRAIC.SHIPSECTION` finds
`Species/Human/sections/CRAIC.shipsection`.
## Performance notes
- Opening `sots.gob` (1.45 GB) + `sots_local_en.gob` (599 MB) takes ~5 ms
warm: two small tail reads plus one central-directory read each, then an
`unordered_map` of ~10k keys. The archives are never loaded whole; each
`read` is one positioned `fread` of exactly that entry (a `FILE*` guarded
by a mutex, so a shared archive is safe to read from several threads).
- Reading and CRC-verifying every entry of both archives (2.15 GB) takes
~4 s from page cache (`SOTS_GOB_FULL=1` in the real-data test), i.e. the
reader is I/O-bound; CRC-32 is miniz's table implementation.
- `list()` is a linear scan over every mount's entries (fine at 10k entries;
a sorted key index would make prefix queries logarithmic if it ever
matters).
- Memory: the central-directory index (name + key + fixed fields per entry)
is ≈2 MB for both archives; the native index is proportional to the number
of loose files.
## Tests
`tests/mars_vfs/build_and_run.sh` (plain g++, `-Wall -Wextra -Werror`) or the
optional `tests/mars_vfs/CMakeLists.txt` (`mars_vfs_unit`, `mars_vfs_realdata`).
Unit tests (`unit_tests.cpp`, 13 cases) build ZIPs in-process with an
independent minimal writer (`zip_builder.h`: stored entries, directory
placeholders, and raw pre-packed records for fabricating deflate / encrypted
/ odd-method entries). Covered: index + case/separator-insensitive lookup,
local-vs-central extra lengths, trailing comment and appended junk,
duplicate names, corrupt EOCD (missing / truncated / bad signature / bad
directory offset / bad directory signature / ZIP64 sentinel), corrupt
entries (flipped data byte → CRC, bad local signature, encrypted, method 12,
stored size mismatch, garbage deflate stream), override precedence under both
`Order`s, `list` union/dedup/prefix/spelling, native mount case folding and
rescan, `..` containment, and the empty VFS. The one deflated fixture is
produced by Python's `zipfile` (`make_fixture.py`, an independent
compressor) at build time; that case SKIPs when Python is absent.
Real-data test (`realdata_test.cpp`) runs when `SOTS_GOB_DIR` holds the two
archives (`SOTS_GOB_ORACLE_DIR` the `unzip -l` listings, default the same
dir; `SOTS_GOB_FULL=1` adds the read-everything pass) and SKIPs otherwise.
### Oracle results (2026-09-07, owner's archives)
| check | ours | oracle |
|---|---|---|
| `sots.gob` central-directory entries | 8,352 (100 dirs, 8,252 files) | `unzip -l`: 8,352 |
| `sots_local_en.gob` entries | 2,035 (19 dirs, 2,016 files) | `unzip -l`: 2,035 |
| every oracle name present with equal size, no duplicates | yes | — |
| entries using a compression method | 0 | notes: all stored |
| `TechTree/MasterTechList.tech` → `mars_parse` | 293 `tech` blocks, 62,234 bytes | 293 |
| `Locale/EN/Strings.csv` → `mars_text` | 5,722 raw records / 5,200 data rows | 5,722 |
| `*.weapon` via `list()` | 207 (123 under `Weapons/`, 84 under `Species/_NPC/`) | 207 |
| loose-file override of `MasterTechList.tech` | native wins (`NativeFirst`); zip wins with native registered last under `Registration` | notes' rule |
| read + CRC-verify all 10,268 files (2.15 GB) | 0 errors, ~4 s | — |
| byte-equality vs `unzip -p` (asked with upper-case + backslash spelling) | `MasterTechList.tech`, `_weapons.txt`, `globals.txt`, `AI_AV.tga`, `CRAIC.shipsection`, `Strings.csv` all identical | — |
The notes' "8,354 / 2,037 entries" are the `unzip -l` line counts including
the footer; the archives hold 8,352 / 2,035 directory records.
## Open questions
- **Load order in the original.** The notes only establish "native override,
archives searched in order"; whether `sots_local_en.gob` is consulted
before or after `sots.gob`, and whether a `Mods/` directory is one native
mount or several, is unknown. `Order` and mount registration order keep
both answers expressible.
- **Directory placeholders.** Whether the engine ever asks "does directory X
exist" is unknown; `exists()` currently answers files only.
- **Native-mount freshness.** The index is a snapshot; if the engine expects
files dropped into the game directory mid-session to appear, callers must
`rescan_native()`.
- **Time stamps.** DOS time/date are exposed raw; nothing in the notes says
the engine reads them.