From 733bfcf6fa2e17dbc94487fb1b9eb4f896e53845 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 15:15:29 +0400 Subject: [PATCH] perf(assets): reuse archives during mission loads --- crates/fparkan-assets/src/lib.rs | 6 ++-- crates/fparkan-resource/src/lib.rs | 45 ++++++++++++++++++++++++++++++ docs/tomes/05-render.md | 22 +++++++++++---- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index 58fa5ed..5bb3edf 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/src/lib.rs @@ -1863,7 +1863,7 @@ fn read_key( ) -> Result, AssetError> { let label = label.unwrap_or("asset"); let archive = repository - .open_archive(&key.archive) + .open_archive_unchanged(&key.archive) .map_err(|err| map_resource_error(label, key, err))?; let handle = repository .find(archive, &key.name) @@ -1928,7 +1928,7 @@ fn resolve_wear_table( mesh: &ResourceKey, ) -> Result { let archive = repository - .open_archive(&mesh.archive) + .open_archive_unchanged(&mesh.archive) .map_err(|err| map_resource_error("wear", mesh, err))?; let wear_name = sibling_name(mesh, "wea")?; let handle = repository @@ -2271,7 +2271,7 @@ fn read_optional_key( key: &ResourceKey, label: Option<&str>, ) -> Result>, AssetError> { - let archive = match repository.open_archive(&key.archive) { + let archive = match repository.open_archive_unchanged(&key.archive) { Ok(archive) => archive, Err(ResourceError::MissingArchive { .. } | ResourceError::MissingEntry) => return Ok(None), Err(err) => { diff --git a/crates/fparkan-resource/src/lib.rs b/crates/fparkan-resource/src/lib.rs index 8fbd62e..f996a71 100644 --- a/crates/fparkan-resource/src/lib.rs +++ b/crates/fparkan-resource/src/lib.rs @@ -258,6 +258,21 @@ pub trait ResourceRepository { /// Returns [`ResourceError`] when the archive is missing, unsupported, or /// malformed. fn open_archive(&self, path: &NormalizedPath) -> Result; + /// Opens an archive known to remain unchanged for the caller's bounded + /// loading transaction. + /// + /// Implementations may reuse an already decoded archive without a second + /// content-fingerprint pass. Callers must use this only while their asset + /// source is immutable; the default preserves [`Self::open_archive`]'s + /// strict invalidation behavior. + /// + /// # Errors + /// + /// Returns [`ResourceError`] when the archive is missing, unsupported, or + /// malformed. + fn open_archive_unchanged(&self, path: &NormalizedPath) -> Result { + self.open_archive(path) + } /// Finds entry. /// /// # Errors @@ -524,6 +539,19 @@ impl ResourceRepository for CachedResourceRepository { } } + fn open_archive_unchanged(&self, path: &NormalizedPath) -> Result { + let key = path.identity_bytes().to_vec(); + let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?; + if let Some(id) = state.paths.get(&key).copied() { + if state.archive(id)?.document.is_some() { + state.touch_archive(id)?; + return Ok(id); + } + } + drop(state); + self.open_archive(path) + } + fn find( &self, archive: ArchiveId, @@ -1101,6 +1129,23 @@ mod tests { assert_eq!(vfs.metadata_reads.load(Ordering::Relaxed), 1); } + #[test] + fn unchanged_transaction_reuses_decoded_archive_without_rereading() { + let path = archive_path(b"archives/test.lib").expect("path"); + let bytes = Arc::from(build_nres(&[("one", b"payload")]).into_boxed_slice()); + let vfs = Arc::new(CountingVfs::new(bytes)); + let repo = CachedResourceRepository::new(Arc::clone(&vfs) as Arc); + + let first = repo.open_archive_unchanged(&path).expect("first open"); + let second = repo + .open_archive_unchanged(&path) + .expect("transactional cached open"); + + assert_eq!(first, second); + assert_eq!(vfs.reads.load(Ordering::Relaxed), 1); + assert_eq!(vfs.metadata_reads.load(Ordering::Relaxed), 1); + } + #[test] fn concurrent_same_archive_open_reuses_archive_id() { let path = archive_path(b"archives/test.lib").expect("path"); diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 4a082f8..543af27 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1457,11 +1457,23 @@ is the base of the `0x1A4` camera object (vtables at `+0` and `+4`), while with its own vtable. This capture format deliberately preserves the former base-object interpretation and does not invent camera ownership. -A bounded first launch of the capture-driven static preview against the -canonical GOG `MISSIONS/Autodemo.00/data.tma` did not reach a native frame -within 60 seconds. It is therefore load-path evidence only, not a rendering or -pixel-parity result; the existing full dependency preparation remains the next -performance/runtime obstacle before this exact capture can be visually compared. +The first bounded launch showed that repeated fingerprinting, rather than +Vulkan initialization, was the load-path bottleneck: each new MAT0/TEXM request +re-opened an already decoded archive and re-hashed its entire source file. +`ResourceRepository::open_archive_unchanged` now makes the *explicit* bounded +loading-transaction contract available. The normal `open_archive` remains the +strict public path and still invalidates stale entry handles after an external +archive replacement; only mission asset preparation uses the transactional +variant because it consumes an immutable snapshot of the selected game root. + +With that scoped cache, canonical GOG +`MISSIONS/Autodemo.00/data.tma --backend static-vulkan --preview-roots 1` +completed preparation in 6.308 seconds (the visual graph completed in 6.012 +seconds) and produced a 1280×720 native Vulkan frame with the captured D3D7 +camera, 15 material descriptors, and zero validation warnings or errors. This +is a real original-asset rendering milestone, but not pixel parity or gameplay +camera parity: it intentionally renders the bounded first preview root and +uses a one-time offline camera capture. A fresh no-input launch of the canonical `iron_3d.exe` did create a responsive window titled `Parkan. Железная Стратегия`. A read-only probe then requested