Opening https://learn.spikersoft.com/hex-tower-defence crashes the browser tab
once more than one Art Studio asset is assigned to a slot. With a single
assigned asset (the Command Center) it worked. The crash leaves no console
error and no stack — the tab simply dies.
Root cause
Two independent defects that only became fatal together.
1. The export stage never decimates (spikersoft-backend)
SpikerSoft.EventHandlers.ArtPipeProcessor/appsettings.json declares the
export stage with Params: { include_animation: "true" } and no target_faces.
art_pipe reads it as int(params.get("target_faces") or 0)
(src/artpipe/models/blender_stage.py), and the game_export operator
decimates only if mesh_obj and self.target_faces > 0
(src/addons/artpipe/game_export.py). So the omission meant no decimation
ever ran — every Art Studio game export ships at raw generative density.
Measured on a stored export-glb (blender_export_1de22c8e.glb, pulled from
GridFS and parsed):
triangles
137,799 (face budget is 5,000–16,000)
vertices
413,215 — exactly 3× triangles, i.e. fully unwelded, zero index reuse
binary buffer
17.77 MB of the 17.77 MB file
attributes
POSITION, NORMAL, COLOR_0 — no TEXCOORD_0
texture
1 × 512×512 PNG, 0.43 MB (irrelevant to the size)
It is not a texture problem — it is raw vertex data.
The unwelded output is a second-order effect of the same bug: the exporter's remove_doubles + normals_make_consistent cleanup sits inside the if applied_decimate: block, so skipping decimation skips the weld too.
Production export-glb sizes today:
asset
export-glb
Game: Main Building
109.51 MB
Test chair
73.17 MB
Hexatile: Zergling
53.85 MB
Chess: Pawn Piece
42.50 MB
Note the export stage makes the asset ~5× bigger than its own texturing-stage
input (21.60 MB → 109.51 MB for Main Building), which is backwards for a
game export.
GetArtAssetManifestQueryHandler → ArtAssetArtifactSelection.PickExportMesh
prefers exactly this artifact, so the game downloads the biggest thing in the
asset.
2. The game loads every slot concurrently (spikersoft-angular)
HexTowerDefenceComponent.syncSlots pushed one applySlot per unapplied slot
into Promise.allSettled. All downloads and their GLTFLoader parses ran
at once, so peak memory was the sum of every model (blob + ArrayBuffer + typed
arrays, all live simultaneously). Four assigned slots ≈ 280 MB of GLB, ~3× that
transient. A renderer-process OOM kills the tab outright — which is exactly why
there was no catchable error.
One asset fit under the limit. Two did not.
Fix
spikersoft-backend: set "target_faces": "6000" on the export stage
(matches the pipeline's own band — EXPORT_DEFAULT_TARGET_FACES = 5000, COVERAGE_PRESET_FASTEST = 6000). Config-only; no artpipe image rebuild.
spikersoft-angular: load slots sequentially; budget the loadout as a whole
(192 MB — the board keeps every registered model resident, so its cost is the
sum of its slots); give the shared game loader per-asset ceilings (128 MB /
1.5M triangles) checked before parsing; surface a distinct "too detailed
for the board" message instead of the generic load failure.
Follow-ups
Re-export the existing assets. The four above keep their oversized export-glb until the export stage is re-run; until then they will trip
the new budget and fall back to procedural art.
or 0 is a latent trap.build_export_commands silently means "no
decimation" when the caller forgets the param. Consider defaulting to EXPORT_DEFAULT_TARGET_FACES there. Deferred: deploying it rebuilds the
36 GiB artpipe-model-prodstages monolith, and the backend config fix
already covers the live path.
Missing TEXCOORD_0. The export references a baseColorTexture the
mesh has no UVs to sample, so appearance rides entirely on COLOR_0.
Worth a separate ticket — the texturing stage's output is being thrown
away at export.
## Symptom
Opening https://learn.spikersoft.com/hex-tower-defence crashes the browser tab
once more than one Art Studio asset is assigned to a slot. With a single
assigned asset (the Command Center) it worked. The crash leaves **no console
error** and no stack — the tab simply dies.
## Root cause
Two independent defects that only became fatal together.
### 1. The export stage never decimates (`spikersoft-backend`)
`SpikerSoft.EventHandlers.ArtPipeProcessor/appsettings.json` declares the
export stage with `Params: { include_animation: "true" }` and **no
`target_faces`**.
art_pipe reads it as `int(params.get("target_faces") or 0)`
(`src/artpipe/models/blender_stage.py`), and the `game_export` operator
decimates only `if mesh_obj and self.target_faces > 0`
(`src/addons/artpipe/game_export.py`). So the omission meant **no decimation
ever ran** — every Art Studio game export ships at raw generative density.
Measured on a stored `export-glb` (`blender_export_1de22c8e.glb`, pulled from
GridFS and parsed):
| | |
|---|---|
| triangles | **137,799** (face budget is 5,000–16,000) |
| vertices | **413,215** — exactly 3× triangles, i.e. fully unwelded, zero index reuse |
| binary buffer | 17.77 MB of the 17.77 MB file |
| attributes | `POSITION`, `NORMAL`, `COLOR_0` — **no `TEXCOORD_0`** |
| texture | 1 × 512×512 PNG, 0.43 MB (irrelevant to the size) |
It is **not** a texture problem — it is raw vertex data.
The unwelded output is a second-order effect of the same bug: the exporter's
`remove_doubles` + `normals_make_consistent` cleanup sits **inside** the
`if applied_decimate:` block, so skipping decimation skips the weld too.
Production `export-glb` sizes today:
| asset | export-glb |
|---|---|
| Game: Main Building | **109.51 MB** |
| Test chair | 73.17 MB |
| Hexatile: Zergling | 53.85 MB |
| Chess: Pawn Piece | 42.50 MB |
Note the export stage makes the asset ~5× **bigger** than its own texturing-stage
input (21.60 MB → 109.51 MB for Main Building), which is backwards for a
game export.
`GetArtAssetManifestQueryHandler` → `ArtAssetArtifactSelection.PickExportMesh`
prefers exactly this artifact, so the game downloads the biggest thing in the
asset.
### 2. The game loads every slot concurrently (`spikersoft-angular`)
`HexTowerDefenceComponent.syncSlots` pushed one `applySlot` per unapplied slot
into `Promise.allSettled`. All downloads **and** their `GLTFLoader` parses ran
at once, so peak memory was the sum of every model (blob + ArrayBuffer + typed
arrays, all live simultaneously). Four assigned slots ≈ 280 MB of GLB, ~3× that
transient. A renderer-process OOM kills the tab outright — which is exactly why
there was no catchable error.
One asset fit under the limit. Two did not.
## Fix
- `spikersoft-backend`: set `"target_faces": "6000"` on the export stage
(matches the pipeline's own band — `EXPORT_DEFAULT_TARGET_FACES` = 5000,
`COVERAGE_PRESET_FASTEST` = 6000). Config-only; no artpipe image rebuild.
- `spikersoft-angular`: load slots sequentially; budget the loadout as a whole
(192 MB — the board keeps every registered model resident, so its cost is the
sum of its slots); give the shared game loader per-asset ceilings (128 MB /
1.5M triangles) checked **before** parsing; surface a distinct "too detailed
for the board" message instead of the generic load failure.
## Follow-ups
- [ ] **Re-export the existing assets.** The four above keep their oversized
`export-glb` until the export stage is re-run; until then they will trip
the new budget and fall back to procedural art.
- [ ] **`or 0` is a latent trap.** `build_export_commands` silently means "no
decimation" when the caller forgets the param. Consider defaulting to
`EXPORT_DEFAULT_TARGET_FACES` there. Deferred: deploying it rebuilds the
36 GiB `artpipe-model-prodstages` monolith, and the backend config fix
already covers the live path.
- [ ] **Missing `TEXCOORD_0`.** The export references a `baseColorTexture` the
mesh has no UVs to sample, so appearance rides entirely on `COLOR_0`.
Worth a separate ticket — the texturing stage's output is being thrown
away at export.
Confirmed by production bisect — and my first fix (#580) was wrong
Bisected against the live page with @spikerj, one variable at a time:
loadout
result
empty
page loads
test5 — 9.18 MB export
page loads, skin renders
Game: Main Building — 109.51 MB export
crashes Chrome and Safari
So the cause is the oversized export, confirmed empirically rather than
inferred.
Two corrections to the original analysis
Concurrency was never the live trigger. Only ONE slot was ever assigned
(building:command-center → 6a64c5c9a616921dff155f2a). The sequential-loading
change in #580 fixes a real latent bug, but it is not what was crashing this page.
#580's guards never fired. I sized them at 128 MB / 1.5M triangles so the
un-decimated assets already in production would still load — which meant the one
asset killing the page passed every check. A budget tuned to accommodate broken
data protects nothing. The byte check also read blob.size, i.e. after the
109 MB was already resident, which on Safari is enough to lose the tab on its own.
Why it appeared to work at first
It is a marginal failure, not a deterministic one:
The loadout's UpdatedAt is byte-identical to its CreatedAt (20:37:51), so no second assignment was ever persisted. onArtAssetPicked loads the model
before saving, so the tab died mid-load and the PATCH never ran — which is why
the DB shows one slot even though several were assigned.
Hexatile: Zergling was created at 20:40:42, three minutes after the loadout
was saved, matching "I generated a few more and went to assign them".
The asset was never re-generated (retry=0), so the 109.51 MB file is the same
one that worked once. One copy sat at the edge of survivable; a second on top
was reliably fatal.
Fixes
backend #486 — export-stage target_faces (the cure) + meshSizeBytes in
the games manifest, so a client can refuse before requesting the body.
angular #581 — budgets sized against a healthy export (24 MB / 60k tris per
asset, 96 MB per loadout), refusal before download, regression test pinned to
the real 109.51 MB asset.
angular #580 — merged and deployed; keeps sequential loading.
## Confirmed by production bisect — and my first fix (#580) was wrong
Bisected against the live page with @spikerj, one variable at a time:
| loadout | result |
|---|---|
| empty | page loads |
| `test5` — 9.18 MB export | page loads, skin renders |
| `Game: Main Building` — 109.51 MB export | crashes Chrome **and** Safari |
So the cause is the **oversized export**, confirmed empirically rather than
inferred.
### Two corrections to the original analysis
**Concurrency was never the live trigger.** Only ONE slot was ever assigned
(`building:command-center` → `6a64c5c9a616921dff155f2a`). The sequential-loading
change in #580 fixes a real latent bug, but it is not what was crashing this page.
**#580's guards never fired.** I sized them at 128 MB / 1.5M triangles so the
un-decimated assets already in production would still load — which meant the one
asset killing the page passed every check. A budget tuned to accommodate broken
data protects nothing. The byte check also read `blob.size`, i.e. after the
109 MB was already resident, which on Safari is enough to lose the tab on its own.
### Why it appeared to work at first
It is a *marginal* failure, not a deterministic one:
- The loadout's `UpdatedAt` is byte-identical to its `CreatedAt` (20:37:51), so
**no second assignment was ever persisted**. `onArtAssetPicked` loads the model
before saving, so the tab died mid-load and the PATCH never ran — which is why
the DB shows one slot even though several were assigned.
- `Hexatile: Zergling` was created at 20:40:42, three minutes after the loadout
was saved, matching "I generated a few more and went to assign them".
- The asset was never re-generated (`retry=0`), so the 109.51 MB file is the same
one that worked once. One copy sat at the edge of survivable; a second on top
was reliably fatal.
### Fixes
- **backend #486** — export-stage `target_faces` (the cure) + `meshSizeBytes` in
the games manifest, so a client can refuse before requesting the body.
- **angular #581** — budgets sized against a healthy export (24 MB / 60k tris per
asset, 96 MB per loadout), refusal before download, regression test pinned to
the real 109.51 MB asset.
- **angular #580** — merged and deployed; keeps sequential loading.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Symptom
Opening https://learn.spikersoft.com/hex-tower-defence crashes the browser tab
once more than one Art Studio asset is assigned to a slot. With a single
assigned asset (the Command Center) it worked. The crash leaves no console
error and no stack — the tab simply dies.
Root cause
Two independent defects that only became fatal together.
1. The export stage never decimates (
spikersoft-backend)SpikerSoft.EventHandlers.ArtPipeProcessor/appsettings.jsondeclares theexport stage with
Params: { include_animation: "true" }and notarget_faces.art_pipe reads it as
int(params.get("target_faces") or 0)(
src/artpipe/models/blender_stage.py), and thegame_exportoperatordecimates only
if mesh_obj and self.target_faces > 0(
src/addons/artpipe/game_export.py). So the omission meant no decimationever ran — every Art Studio game export ships at raw generative density.
Measured on a stored
export-glb(blender_export_1de22c8e.glb, pulled fromGridFS and parsed):
POSITION,NORMAL,COLOR_0— noTEXCOORD_0It is not a texture problem — it is raw vertex data.
The unwelded output is a second-order effect of the same bug: the exporter's
remove_doubles+normals_make_consistentcleanup sits inside theif applied_decimate:block, so skipping decimation skips the weld too.Production
export-glbsizes today:Note the export stage makes the asset ~5× bigger than its own texturing-stage
input (21.60 MB → 109.51 MB for Main Building), which is backwards for a
game export.
GetArtAssetManifestQueryHandler→ArtAssetArtifactSelection.PickExportMeshprefers exactly this artifact, so the game downloads the biggest thing in the
asset.
2. The game loads every slot concurrently (
spikersoft-angular)HexTowerDefenceComponent.syncSlotspushed oneapplySlotper unapplied slotinto
Promise.allSettled. All downloads and theirGLTFLoaderparses ranat once, so peak memory was the sum of every model (blob + ArrayBuffer + typed
arrays, all live simultaneously). Four assigned slots ≈ 280 MB of GLB, ~3× that
transient. A renderer-process OOM kills the tab outright — which is exactly why
there was no catchable error.
One asset fit under the limit. Two did not.
Fix
spikersoft-backend: set"target_faces": "6000"on the export stage(matches the pipeline's own band —
EXPORT_DEFAULT_TARGET_FACES= 5000,COVERAGE_PRESET_FASTEST= 6000). Config-only; no artpipe image rebuild.spikersoft-angular: load slots sequentially; budget the loadout as a whole(192 MB — the board keeps every registered model resident, so its cost is the
sum of its slots); give the shared game loader per-asset ceilings (128 MB /
1.5M triangles) checked before parsing; surface a distinct "too detailed
for the board" message instead of the generic load failure.
Follow-ups
export-glbuntil the export stage is re-run; until then they will tripthe new budget and fall back to procedural art.
or 0is a latent trap.build_export_commandssilently means "nodecimation" when the caller forgets the param. Consider defaulting to
EXPORT_DEFAULT_TARGET_FACESthere. Deferred: deploying it rebuilds the36 GiB
artpipe-model-prodstagesmonolith, and the backend config fixalready covers the live path.
TEXCOORD_0. The export references abaseColorTexturethemesh has no UVs to sample, so appearance rides entirely on
COLOR_0.Worth a separate ticket — the texturing stage's output is being thrown
away at export.
Confirmed by production bisect — and my first fix (#580) was wrong
Bisected against the live page with @spikerj, one variable at a time:
test5— 9.18 MB exportGame: Main Building— 109.51 MB exportSo the cause is the oversized export, confirmed empirically rather than
inferred.
Two corrections to the original analysis
Concurrency was never the live trigger. Only ONE slot was ever assigned
(
building:command-center→6a64c5c9a616921dff155f2a). The sequential-loadingchange in #580 fixes a real latent bug, but it is not what was crashing this page.
#580's guards never fired. I sized them at 128 MB / 1.5M triangles so the
un-decimated assets already in production would still load — which meant the one
asset killing the page passed every check. A budget tuned to accommodate broken
data protects nothing. The byte check also read
blob.size, i.e. after the109 MB was already resident, which on Safari is enough to lose the tab on its own.
Why it appeared to work at first
It is a marginal failure, not a deterministic one:
UpdatedAtis byte-identical to itsCreatedAt(20:37:51), sono second assignment was ever persisted.
onArtAssetPickedloads the modelbefore saving, so the tab died mid-load and the PATCH never ran — which is why
the DB shows one slot even though several were assigned.
Hexatile: Zerglingwas created at 20:40:42, three minutes after the loadoutwas saved, matching "I generated a few more and went to assign them".
retry=0), so the 109.51 MB file is the sameone that worked once. One copy sat at the edge of survivable; a second on top
was reliably fatal.
Fixes
target_faces(the cure) +meshSizeBytesinthe games manifest, so a client can refuse before requesting the body.
asset, 96 MB per loadout), refusal before download, regression test pinned to
the real 109.51 MB asset.