Skip to content

Feat/dat async read#2385

Merged
3vcloud merged 33 commits into
devfrom
feat/dat-async-read
Jul 6, 2026
Merged

Feat/dat async read#2385
3vcloud merged 33 commits into
devfrom
feat/dat-async-read

Conversation

@henderkes

Copy link
Copy Markdown
Member

No description provided.

3vcloud and others added 23 commits July 6, 2026 10:10
Add GwDatArchive::ReadFileAsync(file_id, callback, stream_id): it queues the
request on an internal list and returns immediately. The dat worker, which
now wakes on a short poll interval as well as on tasks, scans the list each
pass - when a file resolves it fires the callback with the decompressed
bytes and drops the entry; if the file hasn't appeared within ~1 minute it
fires the callback once with an empty vector and drops it.

This lets callers wait for files the client streams in on demand (e.g. the
Steam .snapshot archive, which the game persists to disk) without blocking:
the existing throttled MaybeRefresh picks the file up on a later poll. Undelivered
requests are dropped if the worker is stopped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e each)

Add GwDatArchive::SetTrigger(fn): an optional hook the worker calls to ask
the client to load a file id that isn't resident. ProcessPendingReads invokes
it exactly once per pending request (a `triggered` flag guards against
re-asking on every poll), outside the list lock. The pure reader stays free
of any game-function lookup; if no trigger is wired it just waits passively.

GwDatTextureModule resolves the client's request-files function (the safe
FUN_0082da30 variant) by pattern and wires a trigger that issues the request
on the game thread. On a client update the pattern may need re-deriving; it
fails soft (logs, async reads still wait for natural streaming).

Together with ReadFileAsync this closes the loop: request -> client streams
the file into the .snapshot on disk -> MaybeRefresh picks it up -> callback
fires with the bytes, or empty after ~1 minute.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mmit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the 38-byte prologue pattern for the request function with a
string-anchored scan (ToFunctionStart(FindUseOfString(DOWNLOAD_FLAG assert))),
matching the codebase convention and surviving client updates that shift the
byte layout. This retargets from the byte-pattern'd wrapper to the request
function it wraps - safe to call in-game (its requests-suspended assert is
only toggled from the client's Main) and enqueue-only (the client pumps its
download queue itself, so the wrapper's kick isn't needed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Condense the doc comments and fold ProcessPendingReads' deferred-trigger
vector back into the loop (fire the tickle under the lock - it's just a
game-thread enqueue). No behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… files)

Route the texture/greyscale/item-image decode paths and the DDS export
through GwDatArchive::ReadFileAsync: dispatch one async read per image that
waits for the file to be resident (tickling the client to stream it in),
then decode on the worker and upload on the DX thread.

ReadFileAsync owns the retry, the ~1 min give-up, and the fetch trigger, so
the per-frame retry bookkeeping on GwImg (m_attempts, m_first_missed_ms and
the two window constants) is gone; WantsDecode is now just 'no texture, not
yet dispatched'. Gating on one stream is enough since a file's streams
arrive together.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the fetch trigger into ReadFile: on a miss (after the throttled
MaybeRefresh still can't find the file) ask the client to fetch it, throttled
per file id so a per-frame retry loop can't spam. Now every reader benefits -
the sync callers (pathfinding map loads, the decoders' re-reads) prompt a
fetch so a later attempt may find the file, not just the async path.

This unifies the trigger: ReadFileAsync's polling goes through ReadFile too,
so its per-request 'triggered' flag is gone. m_trigger moves to its own mutex
(+ a per-id throttle map) since ReadFile can now tickle while the pending-list
lock is held.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The request function internally allocs a request batch and frees it itself
(FUN_007cdea0 reclaims it after dispatch), and returns only an int cancel
token we discard - so flags=0 is fire-and-forget with nothing to release.
A cancelable flag would hand back a handle that leaks if not released.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… unchanged

AcquireMappingInto already GetFileSizeEx's the fresh handle, so compare it to
the size at the last parse: if the dat hasn't grown there's nothing new to
index, so skip ParseFrom (the ~14MB MFT copy + 600k-entry hashmap rebuild)
that otherwise ran every 2s while any read was pending. Still re-acquires the
handle each time, so an atomic .snapshot rename (which resizes) is caught.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Calling the bare request enqueue left the file queued until the client pumped
it on its own (next map change). Also resolve and call the client's download
flush after each request - the same one-bit clear the request wrapper does for
its 0x2 flag - so the fetch is sent immediately. The flush is a provably-unique
8-byte 'AND dword[flag],~1; RET'; best-effort, so if it's ever not found the
request just falls back to the client's own pump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tionFromNearCall)

Collapse the two scans (string-anchored enqueue + separate flush) into one:
scan a unique call site (PUSH 0x10008; ...; CALL) and FunctionFromNearCall the
target, which lands on the client's request+flush wrapper (FUN_0082da30). Call
it with flags=0x2 so it enqueues AND flushes in one go - files fetch on the fly
instead of at the next map change. Anchoring on the call site's source-derived
constant is more update-resilient than a callee prologue scan, and the wrapper
also no-ops on the suspend flag rather than asserting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A read miss now queues its file id (still throttled per id) instead of firing
the trigger immediately; the worker flushes the accumulated ids to the client
as a single FUN_0082da30(count, ids, 0x2) call after each poll. A burst of
missing files (e.g. a full icon grid on a fresh streamed install) becomes one
game-thread task and one request batch instead of one per file, so no per-file
hitch. TriggerFn is now (ids, count); undelivered tickles are dropped on stop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tTextureModule)

Fold the GwDatArchive singleton (dat mapping, MFT parsing, decompression,
async reads, fetch tickle) into the texture module - it was the only user -
and rename the combined class GwDatTextureModule -> GwDatModule. The archive
read API stays public (the module's own decode/trigger helpers drive it via
Instance()); the internals are private. GwDatArchive.{h,cpp} are removed and
its Win32 code now builds with the module's PCH. Pure move + rename, no
behaviour change; ~20 call sites updated to GwDatModule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…he archive API

ReadFile/ReadFileAsync are now static (they grab Instance() internally), so
callers drop the .Instance() dance. Everything else archive-side that isn't
used outside the module - EnsureLoaded, DatPath, Loaded,
HandleEnumerationBlocked, SetTrigger, StartWorker, StopWorker - moves to
private. The SetTrigger call moved from the anon-namespace helper into
Initialize so it can be private (the helper just resolves the request fn now).

Also drop the dead task queue: EnqueueTask had no callers, so it, m_tasks, and
the WorkerLoop task branch are gone - the worker just polls pending async
reads (m_task_mutex/cv renamed m_worker_mutex/cv).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ReadFileAsync refactor left m_pending set forever after the first attempt,
so WantsDecode (!m_tex && !m_pending) never re-dispatched: an image whose file
wasn't resident within ReadFileAsync's ~1 min window (e.g. an armory item icon
on a streamed install) stayed blank permanently, even after a later map change
streamed the file in. Clear m_pending in the upload callback (as the old code
did) so a failed attempt retries; on success m_tex is set so it won't.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…preloads

RE of the request call sites: FUN_008e3a40 just returns static preload lists
(not a per-file transform), so our FUN_0082da30(1,&id,flags) is correct. But
the client's own file requests use flags 0 / 0x10000 / 0x10008 and never the
0x2 'flush' we added - it isn't how it kicks a download and clearing that flag
mid-play may interfere. Use flag 0 (matches FUN_0082dd80): a plain queued
request the client downloads when it next processes its queue (map change),
which the read retry then picks up. On-the-fly download during play doesn't
appear achievable via this API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…the dat grows

A map can be a long way off, so drop the ~1 min async-read timeout: a pending
read stays queued and keeps re-tickling until the file streams in, then
delivers. Callbacks now fire only on success, so the texture path dispatches
once and leaves m_pending set (retry lives in the archive), which also avoids
looping when a file is resident but its bytes don't decode.

When MaybeRefresh sees the dat grow (files streamed in, e.g. a map load - which
also clears the client's own request queue), clear the tickle throttle so
still-missing pending reads re-request their files on the next pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Decompiling the client's read paths left the per-file crc ambiguous - it CRC32s
the whole MFT for integrity, and the per-file check folds a 4-byte value (file
id?) into the payload CRC - so gating reads on CRC32(payload) blind could reject
every read. Add a temporary, non-gating probe: for the first few reads, log the
stored crc vs CRC32(payload) and CRC32(payload+file_id) so we can confirm the
real formula from a live dat, then flip it to reject mid-write reads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ually fetch

The on-demand fetch never worked: kTriggerFlags was 0, but in the client wrapper
FUN_0082da30 only "flags & 2" calls FUN_00832f00() - the flush that kicks the
download queue. With 0 the file id was enqueued (FUN_007cd160) but never flushed,
so it was only fetched when the client flushed on its own; a file the game never
loads (e.g. an unowned armory item icon) thus never downloaded, never persisted
to the .snapshot, and stayed blank even across restarts. FUN_0082e4f0 confirms
0x2 is safe: it dodges the 0x8 state assert, the 0x10001 offline bail-out, and
the 0x10000/0x1 cancel-handle leak; it enqueues at priority 1 then flushes.

Also add temp diagnostics (tagged // TEMP) to confirm the chain live: the archive
we bind to + file count on load, each outgoing request batch, and each dat-grew
reindex. These plus the crc probe isolate where the pipeline breaks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-bug vs missing)

FUN_00832f00 ("flush") turns out to just clear a state bit, not kick a download,
so the on-demand fetch problem is independent of the request flags. To tell
whether the missed files are genuinely absent (need a download) or resident but
unfound (a lookup/stream bug), have the request log count how many of the batch
already have a base slot in the index. High => read-path bug; ~0 => not local.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… layout

All requested files have a valid base slot, so this is a read bug, not a download
one: ReadRaw finds the base but the stream walk (idx = candidate.id) fails for
stream>0. Dump slots base..base+13 with their c/b/a/size/id/crc for the first few
reads so we can see whether streams are contiguous (base+stream) or chained, and
whether the 24-byte field split (id vs crc) is right.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@henderkes henderkes changed the base branch from master to dev July 6, 2026 16:21
3vcloud and others added 6 commits July 6, 2026 17:12
Traced how the client loads item icons: FUN_004d3660 (CrAppearanceDoll.cpp - the
character dress-up doll, the armory analog) requests a single file via
FUN_0082da30(1,&id,0x10) then opens it at stream 1 (FUN_00470bf0(id,1,0)) to read
the icon. Flag 0x10 is used consistently for UI item/icon/model fetches
(FUN_004c8180 StoreFile, composite loaders FUN_008458e0/FUN_007fa450). We were the
only caller using 0x2 (priority 1 + the no-op flush bit). Match the client: 0x10
enqueues at priority 2 (interactive/UI bucket), no tracking-handle leak, and skips
the 0x8 readiness assert and 0x10001 offline bail-out.

Also confirms stream 1 is the correct icon stream (the client opens it directly).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… target

Flag 0x10 changed nothing and the bound Gw.dat never grows, so the request flag
was never the issue. Steam GW may stream content into a .snapshot (or _temp$.dat)
distinct from the Gw.dat we bind to - in which case our file stays static and no
read ever finds the icon. Log every disk handle whose path looks dat-like
(.dat/.snapshot/temp/$) with size + archive-magic on first load, so we can see if
there is a second, growing file we should be reading instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FUN_0082da30 silently no-ops the whole request while DAT_01075278 (a suspend flag
Main toggles) is non-zero. Resolve that flag from the CMP [addr],0 at the top of
the already-resolved wrapper, and zero it on the game thread immediately before
each request (equivalent to FUN_0082d830(1); its only resume-path effect is this
store). Tests whether Main-suspend is what drops our fetches. Main may re-suspend
on its next tick, and the pump is still gated on a live connection (DAT_010744c8
&1), so this may not be sufficient on its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Terminate() joined the worker then deleted every GwImg, but decode/upload tasks
the worker had already handed to Resources::dx_jobs still held raw GwImg* and
wrote img->m_tex when the render thread drained them afterward - a use-after-free
that corrupted the heap and hung the game. Two fixes:

- Shared-own GwImg (maps hold shared_ptr; QueueDecode captures it in both the
  worker and DX lambdas), so an in-flight task keeps its image alive past
  Terminate and frees it when it completes. Terminate just clears the maps.
- Latch m_terminated (set before StopWorker) so a late ReadFileAsync/render cant
  respawn the worker or queue new reads during/after teardown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eriments

Async reads no longer retry forever. Each pending read gets kMaxReadAttempts (~20s)
of misses per dat-growth epoch; a dat growth (map load) bumps the epoch and resets
the budget so newly-streamed files still get picked up. When the budget runs out
the read delivers an empty vector (treated as "unavailable"), so the consumer
leaves m_tex null (a placeholder) instead of us re-tickling ~500 unfetchable files
every 10s forever. This is the honest landing after confirming unowned-item icons
cannot be fetched on-demand (no in-game download session; the pump dedups our
partially-present files on stream-0 + version). Icons present in Gw.dat still load
normally (all full installs, plus owned/equipped/seen gear on partial ones).

Also strip the investigation scaffolding: the un-suspend SuspendFlag poke, the
crc probe + Crc32, the slot-neighbourhood dump, the dat-handle enumeration, and
the bound-archive / dat-grew / requesting-N temp logs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resigned to reading whatever is already in the on-disk Gw.dat, strip everything
that interfaced with the client: the async ReadFileAsync API + poll worker, the
FUN_0082da30 request trigger + scanner, the per-id tickle/batch, MaybeRefresh and
its epoch/retry-cap bookkeeping, and the associated mutexes/threads/members.

GwDatModule now maps the dat once, parses the MFT once, and reads synchronously;
LoadX decode+upload happens in a single DX-upload task (GwImg stays shared_ptr so
an in-flight task cant touch a freed image). A read that finds the dat but not the
file id Log::Warning()s once suggesting the user run Guild Wars with -image to
download all game data (this is the incomplete-Steam-install case; requesting the
client to stream it was proven unworkable). ~340 fewer lines.

Docs: add a "Missing images in Toolbox" troubleshooting section explaining the
-image download with Steam launch-option and standalone-shortcut setup steps; the
warning links to it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3vcloud and others added 4 commits July 6, 2026 20:31
…te dat

GwDatModule now flags when a read finds the dat mapped but not the requested file
(the incomplete Steam/streaming-install case) and exposes it via MissingDatData().
The Armory window shows an amber footer warning when that is set, telling the user
to run Guild Wars with -image to download all game data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… pieces

Track (per render) whether any armor/weapon piece the window actually shows came
back with no icon, and display the amber -image warning at the TOP of the window
only when that is true AND the dat is genuinely missing data. This scopes it to
the armory's own images (not any module-wide miss) and, by gating on
MissingDatData(), avoids flashing during the normal first-frame decode on a
complete install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@3vcloud 3vcloud merged commit e5fd106 into dev Jul 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants