Skip to content

Commit 3d806b9

Browse files
dalkiaclaude
andauthored
abgen: asset-reuse parity — canonical glb names, shared assets layout, probe (#1)
Scene glb/gltf bundles now carry the upstream converter's deps digest in their canonical name ({hash}_{depsdigest}_{platform}) in both the live/JIT server and abgen-corpus, matching the asset-reuse flow the ab-cdn deployment has run since v49. On by default; ABGEN_ASSET_REUSE=0 opts out for parity runs against pre-v49 reference trees. - naming: filter deps digests to GLB_DEP_EXTENSIONS (.bin + textures), mirroring upstream computeDepsDigest — fixes digest divergence for glbs referencing non-texture uris (also corrects the wearables path); add split_bundle_stem for parsing canonical names - live: per-entity deps-digest map (magenta-tolerant when enabled), canonical naming + digest validation in the JIT build path, shared {version}/assets/ space layout with entity-scoped read fallback, pre-build HEAD probe that lists space hits in the manifest without rebuilding - space: signed HEAD support for the probe - abcdn: flat lane strips the digest for owner-entity resolution - corpus: canonical naming in derive_one_entity behind EffectiveToggles.asset_reuse; strict mode skips digest-less glbs (upstream skipped-assets semantics) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0d5e9fe commit 3d806b9

11 files changed

Lines changed: 508 additions & 25 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,23 @@ Enabled when `ABGEN_S3_BUCKET` is non-empty (or `ABGEN_USE_SPACE=1`):
158158
- `ABGEN_FALLBACK_VERSION` (default `v41`) - extra version prefix tried on space-cache lookups
159159
- `ABGEN_WORLDS_CONTENT_URL` - worlds-content-server fallback for entity/content fetches that miss the primary source (default public worlds server; `0`/`off`/empty disables)
160160

161+
### Asset-reuse mode (upstream converter parity)
162+
ON by default — the ab-cdn deployment has run asset-reuse since v49. Scene glb/gltf bundles use
163+
the upstream converter's canonical naming and shared bucket layout (applies to the JIT server
164+
and `abgen-corpus`). Set `ABGEN_ASSET_REUSE=0` to fall back to legacy `{hash}_{platform}` names
165+
and entity-scoped space keys — needed only for parity runs against pre-v49 reference trees
166+
(e.g. `--live-mode` sampling of v15–v41 vintages, whose manifests list non-digest names).
167+
- glb/gltf bundles are named `{hash}_{depsdigest}_{platform}` — the digest is a 128-bit hash of
168+
the glb's resolved `(file, hash)` dependency pairs (`.bin` + textures), so a glb whose
169+
dependency set changes lands at a new name/key; textures stay `{hash}_{platform}`
170+
- space keys move from entity-scoped `{version}/{entity}/{bundle}` to the shared
171+
`{version}/assets/{bundle}` layout (entity-scoped keys remain a read fallback)
172+
- before building, the space is HEAD-probed at the canonical key; hits are listed in the entity
173+
manifest without rebuilding, so bundles are converted once across entities
174+
- a glb whose deps can't be resolved is skipped (upstream skipped-assets semantics) unless
175+
`ABGEN_MAGENTA_MISSING` is on, in which case unresolvable deps are dropped from the digest and
176+
the build substitutes placeholder textures
177+
161178
### Registry index eager build
162179
On `POST /entities/active|versions`, entities missing a converted bundle are queued for conversion; the
163180
request waits up to the deadline, builds finish in the background. Knobs: `ABGEN_INDEX_EAGER_BUILD`

crate/src/abcdn/handlers/jit.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -547,10 +547,13 @@ pub(super) fn flat_target(path: &str) -> Option<(String, String)> {
547547
return None;
548548
}
549549
let raw = segs[1].strip_suffix(".br").unwrap_or(segs[1]);
550-
let (bare, platform) = raw.rsplit_once('_')?;
551-
if bare.is_empty() || !resolver::is_platform(platform) {
550+
let (stem, platform) = raw.rsplit_once('_')?;
551+
if stem.is_empty() || !resolver::is_platform(platform) {
552552
return None;
553553
}
554+
// Canonical glb names are `{hash}_{depsdigest}_{platform}` — owner-entity
555+
// resolution needs the bare content hash, not the digest-qualified stem.
556+
let (bare, _digest) = crate::naming::split_bundle_stem(stem);
554557
Some((bare.to_string(), platform.to_string()))
555558
}
556559

crate/src/abcdn/handlers/tests.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,16 @@ fn flat_target_table() {
253253
super::flat_target("v41/Qmhash_webgl"),
254254
Some(("Qmhash".to_string(), "webgl".to_string()))
255255
);
256+
// Canonical glb names carry a deps digest — owner resolution gets the
257+
// bare content hash.
258+
assert_eq!(
259+
super::flat_target("v41/Qmhash_0123abcd_windows"),
260+
Some(("Qmhash".to_string(), "windows".to_string()))
261+
);
262+
assert_eq!(
263+
super::flat_target("v41/Qmhash_0123abcd_mac.br"),
264+
Some(("Qmhash".to_string(), "mac".to_string()))
265+
);
256266
assert_eq!(super::flat_target("v41/Qmhash"), None);
257267
assert_eq!(super::flat_target("v41/_windows"), None);
258268
assert_eq!(super::flat_target("manifest/Qmhash_windows"), None);

crate/src/abcdn/mod.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,18 @@ pub async fn build_state(cfg: &Config) -> Result<AppState> {
9090
"ab-cdn S3 space cache ENABLED (read-through + write-back)"
9191
);
9292
}
93+
if crate::clihelp::env_bool("ABGEN_ASSET_REUSE", true) {
94+
tracing::info!(
95+
"asset-reuse mode ON (default): canonical glb names \
96+
({{hash}}_{{depsdigest}}_{{platform}}), shared {{version}}/assets/ space layout, \
97+
pre-build probe"
98+
);
99+
} else {
100+
tracing::warn!(
101+
"asset-reuse mode DISABLED (ABGEN_ASSET_REUSE=0): legacy {{hash}}_{{platform}} \
102+
names + entity-scoped space keys — only for parity against pre-v49 references"
103+
);
104+
}
93105

94106
let live_proxy = crate::live::Proxy::new(pcfg);
95107
let ab_date = live_proxy.date().to_string();

crate/src/bin/abgen-corpus/build.rs

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ pub(crate) fn derive_one_entity(
349349
ent_id: &str,
350350
platform: &str,
351351
uri_cache: &abgen::glbscan::UriCache,
352+
toggles: EffectiveToggles,
352353
) -> Option<EntityEntry> {
353354
let entity = load_entity_json(store, ent_id)?;
354355
let entity_type = entity
@@ -391,7 +392,28 @@ pub(crate) fn derive_one_entity(
391392
if !store.exists(&c.hash) {
392393
continue;
393394
}
394-
let bundle_name = format!("{}_{platform}", c.hash);
395+
let bundle_name = if toggles.asset_reuse && is_glb {
396+
// Magenta-tolerant runs drop unresolvable deps from the digest
397+
// (the build substitutes placeholders); strict runs skip the glb
398+
// entirely, mirroring the upstream converter's skipped-assets
399+
// handling for missing deps.
400+
let digest = store.fetch_mmap(&c.hash).ok().and_then(|bytes| {
401+
abgen::naming::deps_digest_for_glb(
402+
&bytes,
403+
&c.file,
404+
&content_by_file,
405+
toggles.magenta_missing,
406+
)
407+
.map_err(|e| eprintln!("skip {ent_id}/{}: deps digest: {e:#}", c.file))
408+
.ok()
409+
});
410+
match digest {
411+
Some(d) => format!("{}_{d}_{platform}", c.hash),
412+
None => continue,
413+
}
414+
} else {
415+
format!("{}_{platform}", c.hash)
416+
};
395417
if !local_seen.insert(bundle_name.clone()) {
396418
continue;
397419
}
@@ -476,7 +498,7 @@ pub(crate) fn run_fused_entity_ids(
476498
errs.load(Ordering::Relaxed),
477499
);
478500
}
479-
let entry = match derive_one_entity(store, ent_id, primary, &uri_cache) {
501+
let entry = match derive_one_entity(store, ent_id, primary, &uri_cache, toggles) {
480502
Some(e) => e,
481503
None => return,
482504
};
@@ -628,3 +650,115 @@ pub(crate) fn load_entity_json(store: &LocalContentStore, cid: &str) -> Option<s
628650
let bytes = store.fetch(cid).ok()?;
629651
serde_json::from_slice(&bytes).ok()
630652
}
653+
654+
#[cfg(test)]
655+
mod tests {
656+
use super::*;
657+
658+
fn toggles(asset_reuse: bool, magenta_missing: bool) -> EffectiveToggles {
659+
EffectiveToggles {
660+
collection_mode: false,
661+
real_textures: false,
662+
v38_compat: false,
663+
v38_timestamp: 0,
664+
magenta_missing,
665+
asset_reuse,
666+
}
667+
}
668+
669+
fn store_with_entity(tag: &str, content: serde_json::Value) -> LocalContentStore {
670+
let dir = std::env::temp_dir().join(format!(
671+
"abgen-corpus-derive-{tag}-{}",
672+
std::process::id()
673+
));
674+
let _ = std::fs::remove_dir_all(&dir);
675+
let store = LocalContentStore::new(&dir);
676+
let entity = serde_json::json!({"type": "scene", "content": content});
677+
store
678+
.write("bafyentity", entity.to_string().as_bytes())
679+
.unwrap();
680+
store
681+
}
682+
683+
fn glb_names(entry: &EntityEntry) -> Vec<String> {
684+
entry
685+
.bundles
686+
.iter()
687+
.filter(|b| b.cid == "Qmglb")
688+
.map(|b| b.bundle_name.clone())
689+
.collect()
690+
}
691+
692+
const GLTF_JSON: &str = r#"{"asset":{"version":"2.0"},
693+
"images":[{"uri":"t.png"}],"buffers":[{"uri":"a.bin"}]}"#;
694+
695+
#[test]
696+
fn derive_names_glbs_canonically_in_asset_reuse_mode() {
697+
let store = store_with_entity(
698+
"canonical",
699+
serde_json::json!([
700+
{"file": "m.gltf", "hash": "Qmglb"},
701+
{"file": "a.bin", "hash": "Qmbin"},
702+
{"file": "t.png", "hash": "Qmtex"},
703+
]),
704+
);
705+
store.write("Qmglb", GLTF_JSON.as_bytes()).unwrap();
706+
store.write("Qmbin", b"BIN").unwrap();
707+
store.write("Qmtex", b"PNG").unwrap();
708+
let cache = abgen::glbscan::UriCache::new();
709+
710+
let legacy =
711+
derive_one_entity(&store, "bafyentity", "windows", &cache, toggles(false, false))
712+
.unwrap();
713+
assert_eq!(glb_names(&legacy), vec!["Qmglb_windows".to_string()]);
714+
715+
let reuse =
716+
derive_one_entity(&store, "bafyentity", "windows", &cache, toggles(true, false))
717+
.unwrap();
718+
let digest = abgen::naming::compute_deps_digest(&[
719+
("a.bin".to_string(), "Qmbin".to_string()),
720+
("t.png".to_string(), "Qmtex".to_string()),
721+
]);
722+
assert_eq!(glb_names(&reuse), vec![format!("Qmglb_{digest}_windows")]);
723+
// Textures stay hash-keyed: leaves have no inbound dep refs.
724+
assert!(reuse
725+
.bundles
726+
.iter()
727+
.any(|b| b.bundle_name == "Qmtex_windows"));
728+
}
729+
730+
#[test]
731+
fn derive_skips_glb_with_missing_dep_unless_magenta_tolerant() {
732+
let store = store_with_entity(
733+
"missing-dep",
734+
serde_json::json!([
735+
{"file": "m.gltf", "hash": "Qmglb"},
736+
{"file": "t.png", "hash": "Qmtex"},
737+
]),
738+
);
739+
store.write("Qmglb", GLTF_JSON.as_bytes()).unwrap();
740+
store.write("Qmtex", b"PNG").unwrap();
741+
let cache = abgen::glbscan::UriCache::new();
742+
743+
// Strict: unresolvable "a.bin" skips the glb (upstream skipped-assets
744+
// semantics) but leaves the rest of the entity intact.
745+
let strict =
746+
derive_one_entity(&store, "bafyentity", "windows", &cache, toggles(true, false))
747+
.unwrap();
748+
assert!(glb_names(&strict).is_empty());
749+
assert!(strict
750+
.bundles
751+
.iter()
752+
.any(|b| b.bundle_name == "Qmtex_windows"));
753+
754+
// Magenta-tolerant: digest over the resolvable subset.
755+
let tolerant =
756+
derive_one_entity(&store, "bafyentity", "windows", &cache, toggles(true, true))
757+
.unwrap();
758+
let digest = abgen::naming::compute_deps_digest(&[(
759+
"t.png".to_string(),
760+
"Qmtex".to_string(),
761+
)]);
762+
assert_eq!(glb_names(&tolerant), vec![format!("Qmglb_{digest}_windows")]);
763+
}
764+
}

crate/src/bin/abgen-corpus/dedup.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ fn build_candidate(
422422
.iter()
423423
.min()
424424
.ok_or_else(|| anyhow::anyhow!("variant has no claimants"))?;
425-
let entry = derive_one_entity(store, rep, platform, uri_cache)
425+
let entry = derive_one_entity(store, rep, platform, uri_cache, toggles)
426426
.ok_or_else(|| anyhow::anyhow!("re-derive of {rep} failed"))?;
427427
let spec = entry
428428
.bundles

crate/src/bin/abgen-corpus/main.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ pub(crate) struct EffectiveToggles {
8080
pub(crate) v38_compat: bool,
8181
pub(crate) v38_timestamp: i64,
8282
pub(crate) magenta_missing: bool,
83+
84+
/// Canonical `{hash}_{depsdigest}_{platform}` naming for glb/gltf
85+
/// bundles (upstream asset-reuse parity). Default ON (ab-cdn runs
86+
/// asset-reuse since v49); ABGEN_ASSET_REUSE=0 opts out for parity runs
87+
/// against pre-v49 reference trees.
88+
pub(crate) asset_reuse: bool,
8389
}
8490

8591
const BIN_NAME: &str = "abgen-corpus";
@@ -495,6 +501,7 @@ fn run() -> Result<()> {
495501
v38_compat: set_v38 || (!parity_mode && BuildOpts::env_v38_compat()),
496502
v38_timestamp: BuildOpts::env_v38_timestamp(),
497503
magenta_missing: BuildOpts::env_magenta_missing(),
504+
asset_reuse: abgen::clihelp::env_bool("ABGEN_ASSET_REUSE", true),
498505
}
499506
}
500507
Err(msg) => {
@@ -526,7 +533,8 @@ fn run() -> Result<()> {
526533
let cdir = content_dir
527534
.or_else(|| std::env::var(ABGEN_CONTENT_ROOT_ENV).ok())
528535
.unwrap_or_else(|| DEFAULT_CONTENT_ROOT.to_string());
529-
let (m, summary) = from_live_reference(Path::new(live_ref), &cdir, &platform, per_vintage)?;
536+
let (m, summary) =
537+
from_live_reference(Path::new(live_ref), &cdir, &platform, per_vintage, toggles)?;
530538
live_sample_summary = Some(summary);
531539
(m, out_root)
532540
} else if let Some(ids_path) = entity_ids_path {
@@ -592,6 +600,7 @@ fn run() -> Result<()> {
592600
&platform,
593601
cdn_layout,
594602
fetch_missing.then_some(content_server_url.as_str()),
603+
toggles,
595604
)?;
596605
(m, out_root)
597606
} else if !worlds.is_empty() {
@@ -625,7 +634,7 @@ fn run() -> Result<()> {
625634
if ids.is_empty() {
626635
return Err(anyhow!("--world resolved no scene entities"));
627636
}
628-
let m = manifest_from_ids(&ids, &cdir, &platform, cdn_layout)?;
637+
let m = manifest_from_ids(&ids, &cdir, &platform, cdn_layout, toggles)?;
629638
(m, out_root)
630639
} else if let Some(urn) = collection_urn {
631640
if positional.len() != 1 {

crate/src/bin/abgen-corpus/sources.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::build::{derive_one_entity, load_entity_json, IMAGE_EXTS};
2-
use crate::{BundleSpec, ContentItem, EntityEntry, Manifest};
2+
use crate::{BundleSpec, ContentItem, EffectiveToggles, EntityEntry, Manifest};
33
use abgen::glbscan::file_ext_lower;
44
use abgen::hashes;
55
use abgen::local_store::LocalContentStore;
@@ -221,6 +221,7 @@ pub(crate) fn from_entity_ids(
221221
platform: &str,
222222
cdn_layout: bool,
223223
fetch_from: Option<&str>,
224+
toggles: EffectiveToggles,
224225
) -> Result<Manifest> {
225226
let raw = std::fs::read_to_string(ids_path).with_context(|| format!("read {ids_path}"))?;
226227
let ids: Vec<String> = raw
@@ -232,7 +233,7 @@ pub(crate) fn from_entity_ids(
232233
if let Some(csu) = fetch_from {
233234
fetch_ids_into_store(&LocalContentStore::new(content_dir), csu, &ids);
234235
}
235-
manifest_from_ids(&ids, content_dir, platform, cdn_layout)
236+
manifest_from_ids(&ids, content_dir, platform, cdn_layout, toggles)
236237
}
237238

238239
pub(crate) fn contents_base_url(content_server_url: &str) -> String {
@@ -302,6 +303,7 @@ pub(crate) fn manifest_from_ids(
302303
content_dir: &str,
303304
platform: &str,
304305
keep_shared_bundles: bool,
306+
toggles: EffectiveToggles,
305307
) -> Result<Manifest> {
306308
let store = LocalContentStore::new(content_dir);
307309
let missing = AtomicUsize::new(0);
@@ -323,7 +325,7 @@ pub(crate) fn manifest_from_ids(
323325
secs,
324326
);
325327
}
326-
let entry = derive_one_entity(&store, ent_id, platform, &uri_cache);
328+
let entry = derive_one_entity(&store, ent_id, platform, &uri_cache, toggles);
327329
if entry.is_none() {
328330
missing.fetch_add(1, Ordering::Relaxed);
329331
}
@@ -456,6 +458,7 @@ pub(crate) fn from_live_reference(
456458
content_dir: &str,
457459
platform: &str,
458460
per_vintage: usize,
461+
toggles: EffectiveToggles,
459462
) -> Result<(Manifest, serde_json::Value)> {
460463
let ents = scan_live_reference(ref_dir, platform)?;
461464
if ents.is_empty() {
@@ -499,7 +502,7 @@ pub(crate) fn from_live_reference(
499502
.map(|e| (e.entity_id.as_str(), &e.files))
500503
.collect();
501504

502-
let mut m = manifest_from_ids(&ids, content_dir, platform, true)?;
505+
let mut m = manifest_from_ids(&ids, content_dir, platform, true, toggles)?;
503506
let mut dropped_entities = 0usize;
504507
for e in &mut m.entities {
505508
if let Some(files) = allowed.get(e.entity_id.as_str()) {

0 commit comments

Comments
 (0)