Skip to content

Commit f8a5b10

Browse files
committed
Cross-check the doc-ui asset/string index domains between client and server.
Doc-ui responses now carry the producer index-domain digests and the client refuses to de-index when its own domain disagrees, logging both digests and its per-package slice widths. The two ends had drifted -- a wrong-but-in-range index simply names a different asset, so the store rendered every icon as some neighbouring texture with nothing logged. Also fixes a reference cycle in the doc-ui page walk (a self-recursive nested function pinned each response until a gc pass), and a page-only visitor that silently declined the sound refs in a button click-effect, leaving them un-de-indexed until they failed at run time.
1 parent a3464bf commit f8a5b10

12 files changed

Lines changed: 392 additions & 128 deletions

File tree

.efrocachemap

Lines changed: 28 additions & 28 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
### 1.8.0 (build 22994, api 9, 2026-08-20)
1+
### 1.8.0 (build 22995, api 9, 2026-08-21)
22
- Fully implemented asset packages (more on this soon)
33
- App-config committing (dirty-tracking, debounced disk writes, and
44
suspend/shutdown flushes) now lives fully in `babase` instead of routing

pconfig/projectconfig.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"bauiv1lib": "buil"
2424
},
2525
"efrocache_repository_url": "https://files.ballistica.net/cache/ba1",
26-
"engine_build_number": 22994,
26+
"engine_build_number": 22995,
2727
"name": "BallisticaKit",
2828
"public": true,
2929
"python_paths": [
@@ -43,5 +43,5 @@
4343
"tests",
4444
"config"
4545
],
46-
"version": "1.8.0a97"
46+
"version": "1.8.0a98"
4747
}

src/assets/ba_data/python/baenv.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@
5555

5656
# Build number and version of the ballistica binary we expect to be
5757
# using.
58-
TARGET_BALLISTICA_BUILD = 22994
59-
TARGET_BALLISTICA_VERSION = '1.8.0a97'
58+
TARGET_BALLISTICA_BUILD = 22995
59+
TARGET_BALLISTICA_VERSION = '1.8.0a98'
6060

6161

6262
@dataclass

src/assets/ba_data/python/bauiv1lib/docui/_resolve.py

Lines changed: 91 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
SoundSpec,
2727
CollisionMeshSpec,
2828
AssetBucketKind,
29+
AssetIndexContext,
2930
)
31+
from bacommon.langstr import LangStrFlatIndexContext
3032
import bacommon.clienteffect as clfx
3133

3234

@@ -166,8 +168,18 @@ def _deindex_effects(effects: 'list[clfx.Effect]') -> None:
166168
if response.packages:
167169
# Strings first: the effect de-index below consumes them, and
168170
# it expects the two-int form rather than a folded index.
169-
deindex_langstrs(response.page, packages, response.client_effects)
170-
deindex_assets(response.page, packages, response.client_effects)
171+
deindex_langstrs(
172+
response.page,
173+
packages,
174+
response.client_effects,
175+
expect_digest=response.langstr_index_digest,
176+
)
177+
deindex_assets(
178+
response.page,
179+
packages,
180+
response.client_effects,
181+
expect_digest=response.asset_index_digest,
182+
)
171183
_deindex_effects(response.client_effects)
172184
for row in response.page.rows:
173185
if not isinstance(row, dui2.ButtonRow):
@@ -181,11 +193,17 @@ def package_asset_listing(apverid: str) -> list[str] | None:
181193
"""Canonical sorted logical paths for every asset in a package.
182194
183195
The client's half of the flat-index mapping. Deliberately the union
184-
across buckets rather than one bucket: the server derives its
185-
listing from a vendored wrapper tree that groups paths differently
186-
(collision meshes sit in the ``constant`` bucket here but under
187-
``meshes`` there), so only the union is guaranteed to agree. Indexing
188-
the whole package makes the grouping irrelevant.
196+
across buckets rather than one bucket: the server groups the same
197+
paths differently (collision meshes sit in the ``constant`` bucket
198+
here but under ``meshes`` there), so only the union is guaranteed
199+
to agree. Indexing the whole package makes the grouping irrelevant.
200+
201+
This is the *registry*, i.e. what the package actually built, which
202+
is why the server has to vendor an equally complete listing rather
203+
than derive one from its wrapper accessors -- cube maps and the
204+
legacy-language-data blob appear here and have no accessor. When
205+
the two lists disagree the digest check in :func:`_domains_agree`
206+
is what catches it; nothing else would.
189207
190208
``None`` when the package isn't registered at all -- a
191209
resolve-ordering fault -- as distinct from a registered package that
@@ -224,6 +242,8 @@ def deindex_langstrs(
224242
page: dui2.Page,
225243
packages: list[str],
226244
effects: 'list[clfx.Effect] | None' = None,
245+
*,
246+
expect_digest: str | None = None,
227247
) -> None:
228248
"""Unfold a page's flat string indices into the two-int form.
229249
@@ -233,6 +253,11 @@ def deindex_langstrs(
233253
what the native decoder already consumed before folding existed --
234254
so nothing downstream changes.
235255
256+
``expect_digest`` is the producer's
257+
:meth:`~bacommon.langstr.LangStrFlatIndexContext.domain_digest`;
258+
see :func:`deindex_assets` for why a mismatch means we must not
259+
unfold at all.
260+
236261
Fail-visible per slot: a bad index logs and leaves the integer in
237262
place rather than substituting a wrong string.
238263
"""
@@ -248,6 +273,8 @@ def deindex_langstrs(
248273
return
249274

250275
ctx = LangStrFlatIndexContext(packages, package_string_count)
276+
if not _domains_agree(ctx, expect_digest, 'language-string'):
277+
return
251278

252279
def _lstr(val: 'LangStrSpec | int') -> 'LangStrSpec | None':
253280
# Spec-form strings pass through: a response can mix forms, and
@@ -270,10 +297,51 @@ def _lstr(val: 'LangStrSpec | int') -> 'LangStrSpec | None':
270297
clfx.walk_effects(effects, langstr=_lstr)
271298

272299

300+
def _domains_agree(
301+
ctx: 'AssetIndexContext | LangStrFlatIndexContext',
302+
expect_digest: str | None,
303+
what: str,
304+
) -> bool:
305+
"""Whether an index domain matches the one the producer used.
306+
307+
The two ends build their domains from different sources, so they
308+
can disagree -- and a disagreement hides itself: an index that is
309+
wrong but still in range names a different asset or string, so the
310+
page renders wrong and nothing raises. The digest is the only thing
311+
that makes that visible, which is why a mismatch has to stop the
312+
de-index entirely rather than proceed per-slot. Leaving the
313+
integers in place lands the failure on the existing loud paths.
314+
315+
A payload with no digest (an older producer) is taken as agreeing;
316+
the digest is a guard, not a requirement.
317+
"""
318+
import bauiv1 as bui
319+
320+
if expect_digest is None:
321+
return True
322+
ours = ctx.domain_digest()
323+
if ours == expect_digest:
324+
return True
325+
bui.uilog.error(
326+
'Doc-ui %s index domain disagrees with the producer'
327+
' (ours %s, theirs %s); refusing to de-index, so this page will'
328+
' render incompletely. Our per-package sizes: %s. This means the'
329+
' two ends derive different listings for some package above;'
330+
' compare those sizes against the producer side.',
331+
what,
332+
ours,
333+
expect_digest,
334+
ctx.describe_domain(),
335+
)
336+
return False
337+
338+
273339
def deindex_assets(
274340
page: dui2.Page,
275341
packages: list[str],
276342
effects: 'list[clfx.Effect] | None' = None,
343+
*,
344+
expect_digest: str | None = None,
277345
) -> None:
278346
"""Replace a page's flat asset indices with real specs.
279347
@@ -282,27 +350,25 @@ def deindex_assets(
282350
-- prep, render, the depiction code -- therefore only ever sees
283351
specs, and never has to know the indexed form existed.
284352
353+
``expect_digest`` is the producer's
354+
:meth:`~bacommon.assetspec.AssetIndexContext.domain_digest`; a
355+
mismatch skips de-indexing entirely (see :func:`_domains_agree`).
356+
285357
Fail-visible per reference: a bad index logs and leaves the integer
286358
in place rather than substituting a wrong asset, and the render then
287359
fails on that one slot.
288360
"""
289361
import bauiv1 as bui
290362

291-
# Runtime imports: the narrowing below is real isinstance work, not
292-
# just annotation.
293-
from bacommon.assetspec import (
294-
AssetIndexContext,
295-
AssetIndexError,
296-
TextureSpec,
297-
MeshSpec,
298-
SoundSpec,
299-
)
363+
from bacommon.assetspec import AssetIndexContext, AssetIndexError
300364
from bacommon.docui.walk import walk_page
301365

302366
if not packages:
303367
return
304368

305369
ctx = AssetIndexContext(packages, package_asset_listing)
370+
if not _domains_agree(ctx, expect_digest, 'asset'):
371+
return
306372

307373
def _convert(
308374
ref: 'TextureSpec | MeshSpec | SoundSpec | int',
@@ -320,33 +386,20 @@ def _convert(
320386
)
321387
return None
322388

323-
# Two thin wrappers rather than one visitor: each walk's slots hold
324-
# a different spec union, and a visitor's *return* has to fit the
325-
# slot it is written back into.
326-
def _pageref(
327-
ref: 'TextureSpec | MeshSpec | int', kind: 'AssetBucketKind'
328-
) -> 'TextureSpec | MeshSpec | int | None':
329-
out = _convert(ref, kind)
330-
# Page slots hold textures and meshes only; anything else means
331-
# the slot's kind and the domain disagreed, which should fail
332-
# loudly rather than write a wrong type into the page.
333-
if out is None or isinstance(out, (TextureSpec, MeshSpec)):
334-
return out
335-
return None
336-
337-
def _effectref(
338-
ref: 'SoundSpec | int', kind: 'AssetBucketKind'
339-
) -> 'SoundSpec | int | None':
340-
out = _convert(ref, kind)
341-
return out if isinstance(out, SoundSpec) else None
342-
343-
walk_page(page, assetref=_pageref)
389+
# One visitor for every slot, not one per spec union. `walk_page`
390+
# hands a button's immediate client-effects to `walk_effects` using
391+
# the *page* visitor, so a textures-and-meshes-only visitor silently
392+
# dropped the sound refs in them -- they stayed integers and failed
393+
# at run time with 'Un-de-indexed sound ref N in a client-effect'.
394+
# The slot's own `kind` already decides which spec `_convert`
395+
# produces, so no narrowing is needed (or correct) here.
396+
walk_page(page, assetref=_convert) # type: ignore[arg-type]
344397
if effects:
345398
# Response-level effects are outside the page walk, and their
346399
# sounds are indexed the same as page art.
347400
import bacommon.clienteffect as clfx
348401

349-
clfx.walk_effects(effects, assetref=_effectref)
402+
clfx.walk_effects(effects, assetref=_convert) # type: ignore[arg-type]
350403

351404

352405
def _resolve_packages_blocking(apverids: list[str], locale: Locale) -> None:

src/ballistica/shared/ballistica.cc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ auto main(int argc, char** argv) -> int {
5151
namespace ballistica {
5252

5353
// These are set automatically via script; don't modify them here.
54-
const int kEngineBuildNumber = 22994;
55-
const char* kEngineVersion = "1.8.0a97";
54+
const int kEngineBuildNumber = 22995;
55+
const char* kEngineVersion = "1.8.0a98";
5656
const int kEngineApiVersion = 9;
5757

5858
#if BA_MONOLITHIC_BUILD

tests/test_bacommon/test_assetspec_index.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,3 +178,63 @@ def test_empty_manifest() -> None:
178178
assert ctx.domain_size() == 0
179179
with pytest.raises(AssetIndexError):
180180
ctx.from_index(0, AssetBucketKind.TEXTURES)
181+
182+
183+
def test_digest_catches_a_short_listing() -> None:
184+
"""A listing short by one entry is a different domain.
185+
186+
The failure this guards is silent: dropping an entry from an early
187+
package leaves every later index in range but pointing one slot
188+
off, so the consumer renders the wrong asset and nothing raises.
189+
Only the digest distinguishes the two domains.
190+
"""
191+
short = dict(_LISTINGS)
192+
short[PKG_A] = ['meshes/box', 'textures/ant']
193+
194+
full_ctx = _ctx()
195+
short_ctx = AssetIndexContext([PKG_A, PKG_B, PKG_C], short.get)
196+
197+
# Same index, different asset -- with no error either way.
198+
idx = full_ctx.to_index(TextureSpec(PKG_C, 'textures/dog'))
199+
assert full_ctx.from_index(idx, AssetBucketKind.TEXTURES).name == (
200+
'textures/dog'
201+
)
202+
assert short_ctx.from_index(idx, AssetBucketKind.TEXTURES).name != (
203+
'textures/dog'
204+
)
205+
assert full_ctx.domain_digest() != short_ctx.domain_digest()
206+
207+
208+
def test_digest_is_stable_for_the_same_domain() -> None:
209+
"""Two contexts over the same listings agree."""
210+
assert _ctx().domain_digest() == _ctx().domain_digest()
211+
212+
213+
def test_digest_notices_reordered_packages() -> None:
214+
"""Manifest order is part of the domain, so it is part of the digest."""
215+
assert (
216+
_ctx([PKG_A, PKG_C]).domain_digest()
217+
!= _ctx([PKG_C, PKG_A]).domain_digest()
218+
)
219+
220+
221+
def test_unknown_and_empty_packages_agree() -> None:
222+
"""A package neither end lists is zero-width on both, so not drift.
223+
224+
``PKG_B`` is empty here; a producer that simply has no listing for
225+
it lays out the same domain, and flagging that as a mismatch would
226+
fire on every strings-only package in a manifest.
227+
"""
228+
absent = {k: v for k, v in _LISTINGS.items() if k != PKG_B}
229+
assert (
230+
AssetIndexContext([PKG_A, PKG_B, PKG_C], absent.get).domain_digest()
231+
== _ctx().domain_digest()
232+
)
233+
234+
235+
def test_describe_domain_names_each_package() -> None:
236+
"""The mismatch log has to point at a package, not just fail."""
237+
text = _ctx().describe_domain()
238+
assert f'{PKG_A}=3' in text
239+
assert f'{PKG_B}=0' in text
240+
assert f'{PKG_C}=4' in text

0 commit comments

Comments
 (0)